如需在线了解并订购本书及其他 Manning 书籍,请访问www.manning.com。出版商在订购本书时提供折扣。如需更多信息,请联系
For online information and ordering of this and other Manning books, please visit www.manning.com. The publisher offers discounts on this book when ordered in quantity. For more information, please contact
特约销售部
曼宁出版公司
鲍德温路 20 号
邮政信箱 761
纽约州谢尔特岛 11964
电子邮件: orders@manning.com Special Sales Department
Manning Publications Co.
20 Baldwin Road
PO Box 761
Shelter Island, NY 11964
Email: orders@manning.com
©2020 Manning Publications Co. 保留所有权利。
©2020 by Manning Publications Co. All rights reserved.
未经出版商事先书面许可,不得以任何形式或通过电子、机械、影印或其他方式复制、存储在检索系统中或传播本出版物的任何部分。
No part of this publication may be reproduced, stored in a retrieval system, or transmitted, in any form or by means electronic, mechanical, photocopying, or otherwise, without prior written permission of the publisher.
制造商和销售商用来区分其产品的许多名称均已声明为商标。这些名称出现在书中,并且 Manning Publications 知道商标声明,这些名称均以首字母大写或全部大写印刷。
Many of the designations used by manufacturers and sellers to distinguish their products are claimed as trademarks. Where those designations appear in the book, and Manning Publications was aware of a trademark claim, the designations have been printed in initial caps or all caps.
认识到保存已写内容的重要性,Manning 的政策是将我们出版的书籍印刷在无酸纸上,并为此尽最大努力。同时认识到我们有责任保护地球资源,Manning 书籍印刷在至少 15% 的回收纸上,且在加工过程中不使用元素氯。
Recognizing the importance of preserving what has been written, it is Manning’s policy to have the books we publish printed on acid-free paper, and we exert our best efforts to that end. Recognizing also our responsibility to conserve the resources of our planet, Manning books are printed on paper that is at least 15 percent recycled and processed without the use of elemental chlorine.
曼宁出版公司 鲍德温路 20 号 邮政信箱 761 纽约州谢尔特岛 11964 Manning Publications Co. 20 Baldwin Road PO Box 761 Shelter Island, NY 11964 |
收购编辑:Mike Stephens 开发编辑:Marina Michaels 技术开发编辑:Sam Zaydel 评论编辑:Aleksandar Dragosavljević 制作编辑:Anthony Calcara 文字编辑:Tiffany Taylor ESL 文字编辑:Frances Buran 校对:Keri Hales 技术校对:Alessandro Campeis 排字员:丹尼斯·达林尼克 (Dennis Dalinnik) 封面设计:Marija Tudor
Acquisitions editor: Mike Stephens Development editor: Marina Michaels Technical development editor: Sam Zaydel Review editor: Aleksandar Dragosavljević Production editor: Anthony Calcara Copy editor: Tiffany Taylor ESL copyeditor: Frances Buran Proofreader: Keri Hales Technical proofreader: Alessandro Campeis Typesetter: Dennis Dalinnik Cover designer: Marija Tudor
国际标准书号:9781617296277
ISBN: 9781617296277
美国印刷
Printed in the United States of America
Chapter 1. The goal of unit testing
2. Making your tests work for you
Chapter 4. The four pillars of a good unit test
Chapter 5. Mocks and test fragility
Chapter 8. Why integration testing?
Chapter 1. The goal of unit testing
1.1. The current state of unit testing
1.3. Using coverage metrics to measure test suite quality
1.3.1. Understanding the code coverage metric
1.3.2. Understanding the branch coverage metric
1.4. What makes a successful test suite?
1.4.1. It’s integrated into the development cycle
1.4.2. It targets only the most important parts of your code base
1.4.3. It provides maximum value with minimum maintenance costs
Chapter 2. What is a unit test?
2.1. The definition of “unit test”
2.2. The classical and London schools of unit testing
2.2.1. How the classical and London schools handle dependencies
2.3. Contrasting the classical and London schools of unit testing
2.3.1. Unit testing one class at a time
2.3.2. Unit testing a large graph of interconnected classes
2.3.3. Revealing the precise bug location
2.3.4. Other differences between the classical and London schools
2.4. Integration tests in the two schools
Chapter 3. The anatomy of a unit test
3.1. How to structure a unit test
3.1.2. Avoid multiple arrange, act, and assert sections
3.1.3. Avoid if statements in tests
3.1.4. How large should each section be?
3.1.5. How many assertions should the assert section hold?
3.1.6. What about the teardown phase?
3.1.7. Differentiating the system under test
3.1.8. Dropping the arrange, act, and assert comments from tests
3.2. Exploring the xUnit testing framework
3.3. Reusing test fixtures between tests
3.3.1. High coupling between tests is an anti-pattern
3.3.2. The use of constructors in tests diminishes test readability
3.5. Refactoring to parameterized tests
3.6. Using an assertion library to further improve test readability
2. Making your tests work for you
Chapter 4. The four pillars of a good unit test
4.1. Diving into the four pillars of a good unit test
4.1.1. The first pillar: Protection against regressions
4.1.2. The second pillar: Resistance to refactoring
4.1.3. What causes false positives?
4.1.4. Aim at the end result instead of implementation details
4.2. The intrinsic connection between the first two attributes
4.2.1. Maximizing test accuracy
4.2.2. The importance of false positives and false negatives: The dynamics
4.3. The third and fourth pillars: Fast feedback and maintainability
4.4. In search of an ideal test
4.4.1. Is it possible to create an ideal test?
4.4.2. Extreme case #1: End-to-end tests
4.4.3. Extreme case #2: Trivial tests
4.5. Exploring well-known test automation concepts
Chapter 5. Mocks and test fragility
5.1. Differentiating mocks from stubs
5.1.1. The types of test doubles
5.1.2. Mock (the tool) vs. mock (the test double)
5.1.3. Don’t assert interactions with stubs
5.2. Observable behavior vs. implementation details
5.2.1. Observable behavior is not the same as a public API
5.2.2. Leaking implementation details: An example with an operation
5.2.3. Well-designed API and encapsulation
5.2.4. Leaking implementation details: An example with state
5.3. The relationship between mocks and test fragility
5.3.1. Defining hexagonal architecture
5.3.2. Intra-system vs. inter-system communications
5.3.3. Intra-system vs. inter-system communications: An example
5.4. The classical vs. London schools of unit testing, revisited
5.4.1. Not all out-of-process dependencies should be mocked out
Chapter 6. Styles of unit testing
6.1. The three styles of unit testing
6.1.1. Defining the output-based style
6.2. Comparing the three styles of unit testing
6.2.1. Comparing the styles using the metrics of protection against regressions and feedback speed
6.2.2. Comparing the styles using the metric of resistance to refactoring
6.2.3. Comparing the styles using the metric of maintainability
6.3. Understanding functional architecture
6.3.1. What is functional programming?
6.4. Transitioning to functional architecture and output-based testing
6.4.1. Introducing an audit system
6.4.2. Using mocks to decouple tests from the filesystem
6.5. Understanding the drawbacks of functional architecture
Chapter 7. Refactoring toward valuable unit tests
7.1. Identifying the code to refactor
7.1.2. Using the Humble Object pattern to split overcomplicated code
7.2. Refactoring toward valuable unit tests
7.2.1. Introducing a customer management system
7.2.2. Take 1: Making implicit dependencies explicit
7.2.3. Take 2: Introducing an application services layer
7.2.4. Take 3: Removing complexity from the application service
7.3. Analysis of optimal unit test coverage
7.3.1. Testing the domain layer and utility code
7.4. Handling conditional logic in controllers
7.4.1. Using the CanExecute/Execute pattern
7.4.2. Using domain events to track changes in the domain model
Chapter 8. Why integration testing?
8.1. What is an integration test?
8.1.1. The role of integration tests
8.2. Which out-of-process dependencies to test directly
8.2.1. The two types of out-of-process dependencies
8.2.2. Working with both managed and unmanaged dependencies
8.2.3. What if you can’t use a real database in integration tests?
8.3. Integration testing: An example
8.3.1. What scenarios to test?
8.3.2. Categorizing the database and the message bus
8.4. Using interfaces to abstract dependencies
8.4.1. Interfaces and loose coupling
8.5. Integration testing best practices
8.5.1. Making domain model boundaries explicit
8.5.2. Reducing the number of layers
8.6. How to test logging functionality
8.6.1. Should you test logging?
8.6.2. How should you test logging?
Chapter 9. Mocking best practices
9.1.1. Verifying interactions at the system edges
9.2.1. Mocks are for integration tests only
9.2.2. Not just one mock per test
Chapter 10. Testing the database
10.1. Prerequisites for testing the database
10.1.1. Keeping the database in the source control system
10.1.2. Reference data is part of the database schema
10.2. Database transaction management
10.3.1. Parallel vs. sequential test execution
10.4. Reusing code in test sections
10.4.1. Reusing code in arrange sections
10.4.2. Reusing code in act sections
10.4.3. Reusing code in assert sections
10.4.4. Does the test create too many database transactions?
10.5. Common database testing questions
Chapter 11. Unit testing anti-patterns
11.1. Unit testing private methods
11.1.1. Private methods and test fragility
11.3. Leaking domain knowledge to tests
11.5. Mocking concrete classes
我记得我的第一个项目,我尝试了单元测试。测试进行得还算顺利;但完成后,我看了看测试,觉得很多测试都是纯粹浪费时间。我的大多数单元测试都花了大量时间来设置期望值并连接复杂的依赖关系网络——所有这些只是为了检查控制器中的三行代码是否正确。我无法准确指出测试到底出了什么问题,但我的分寸感向我发出了明确的信号,表明有些地方不对劲。
I remember my first project where I tried out unit testing. It went relatively well; but after it was finished, I looked at the tests and thought that a lot of them were a pure waste of time. Most of my unit tests spent a great deal of time setting up expectations and wiring up a complicated web of dependencies—all that, just to check that the three lines of code in my controller were correct. I couldn’t pinpoint what exactly was wrong with the tests, but my sense of proportion sent me unambiguous signals that something was off.
幸运的是,我没有放弃单元测试,而是在后续项目中继续应用它。然而,从那时起,我对当时常见的单元测试实践的不满就与日俱增。多年来,我写了很多关于单元测试的文章。在这些文章中,我终于弄清楚了我的第一次测试到底出了什么问题,并将这些知识推广到更广泛的单元测试领域。这本书是我在那段时间里所有研究、试验和错误的总结——经过汇编、提炼和提炼。
Luckily, I didn’t abandon unit testing and continued applying it in subsequent projects. However, disagreement with common (at that time) unit testing practices has been growing in me ever since. Throughout the years, I’ve written a lot about unit testing. In those writings, I finally managed to crystallize what exactly was wrong with my first tests and generalized this knowledge to broader areas of unit testing. This book is a culmination of all my research, trial, and error during that period—compiled, refined, and distilled.
我来自数学背景,并坚信编程中的指导方针,就像数学中的定理一样,应该从基本原理中推导出来。我试图以类似的方式构建这本书:从一张白纸开始,不要仓促下结论或抛出未经证实的主张,然后从头开始逐渐建立我的案例。有趣的是,一旦你建立了这样的基本原理,指导方针和最佳实践通常会自然而然地以简单的暗示形式出现。
I come from a mathematical background and strongly believe that guidelines in programming, like theorems in math, should be derived from first principles. I’ve tried to structure this book in a similar way: start with a blank slate by not jumping to conclusions or throwing around unsubstantiated claims, and gradually build my case from the ground up. Interestingly enough, once you establish such first principles, guidelines and best practices often flow naturally as mere implications.
我相信单元测试正在成为软件项目的事实上的要求,这本书将为您提供创建有价值、高度可维护的测试所需的一切。
I believe that unit testing is becoming a de facto requirement for software projects, and this book will give you everything you need to create valuable, highly maintainable tests.
写这本书需要付出很多努力。尽管我已经做好了心理准备,但工作量还是比我想象的要大得多。
This book was a lot of work. Even though I was prepared mentally, it was still much more work than I could ever have imagined.
非常感谢 Sam Zaydel、Alessandro Campeis、Frances Buran、Tiffany Taylor,尤其是 Marina Michaels,他们的宝贵反馈帮助我完善了这本书,让我成为了更好的作家。还要感谢 Manning 的所有参与本书制作和幕后工作的人。
A big “thank you” to Sam Zaydel, Alessandro Campeis, Frances Buran, Tiffany Taylor, and especially Marina Michaels, whose invaluable feedback helped shape the book and made me a better writer along the way. Thanks also to everyone else at Manning who worked on this book in production and behind the scenes.
我还要感谢在我的手稿开发的各个阶段花时间阅读并提供宝贵反馈的审阅者:Aaron Barton、Alessandro Campeis、Conor Redmond、Dror Helper、Greg Wright、Hemant Koneru、Jeremy Lange、Jorge Ezequiel Bo、Jort Rodenburg、Mark Nenadov、Marko Umek、Markus Matzker、Srihari Sridharan、Stephen John Warnett、Sumant Tambe、Tim van Deurzen 和 Vladimir Kuptsov。
I’d also like to thank the reviewers who took the time to read my manuscript at various stages during its development and who provided valuable feedback: Aaron Barton, Alessandro Campeis, Conor Redmond, Dror Helper, Greg Wright, Hemant Koneru, Jeremy Lange, Jorge Ezequiel Bo, Jort Rodenburg, Mark Nenadov, Marko Umek, Markus Matzker, Srihari Sridharan, Stephen John Warnett, Sumant Tambe, Tim van Deurzen, and Vladimir Kuptsov.
我最想感谢的是我的妻子尼娜,她在整个过程中都给予我支持。
Above all, I would like to thank my wife Nina, who supported me during the whole process.
《单元测试:原则、实践和模式》深入介绍了单元测试方面的最佳实践和常见反模式。阅读本书后,凭借新学到的技能,您将掌握所需的知识,成为交付成功项目的专家,这些项目易于维护和扩展,这要归功于您在此过程中构建的测试。
Unit Testing: Principles, Practices, and Patterns provides insights into the best practices and common anti-patterns that surround the topic of unit testing. After reading this book, armed with your newfound skills, you’ll have the knowledge needed to become an expert at delivering successful projects that are easy to maintain and extend, thanks to the tests you build along the way.
大多数在线和印刷资源都有一个缺点:它们专注于单元测试的基础知识,但并没有超出这个范围。这些资源很有价值,但学习并不止于此。还有下一个层次:不仅仅是编写测试,而是以一种让你的努力获得最佳回报的方式进行测试。当你到达学习曲线的这个点时,你几乎只能靠自己的能力去弄清楚如何进入下一个层次。
Most online and print resources have one drawback: they focus on the basics of unit testing but don’t go much beyond that. There’s a lot of value in such resources, but the learning doesn’t end there. There’s a next level: not just writing tests, but doing it in a way that gives you the best return on your efforts. When you reach this point on the learning curve, you’re pretty much left to your own devices to figure out how to get to the next level.
本书将带你进入下一个层次。它教授了理想单元测试的科学、精确定义。该定义提供了一个通用的参考框架,它将帮助你从新的角度看待你的许多测试,并看看其中哪些对项目有贡献,哪些必须重构或删除。
This book takes you to that next level. It teaches a scientific, precise definition of the ideal unit test. That definition provides a universal frame of reference, which will help you look at many of your tests in a new light and see which of them contribute to the project and which must be refactored or removed.
如果您没有太多单元测试经验,那么这本书会让您受益匪浅。如果您是一位经验丰富的程序员,那么您很可能已经理解了本书中传授的一些思想。本书将帮助您阐明为什么您一直在使用的技术和最佳实践如此有用。不要低估这项技能:向同事清晰地传达您的想法的能力是无价的。
If you don’t have much experience with unit testing, you’ll learn a lot from this book. If you’re an experienced programmer, you most likely already understand some of the ideas taught in this book. The book will help you articulate why the techniques and best practices you’ve been using all along are so helpful. And don’t underestimate this skill: the ability to clearly communicate your ideas to colleagues is priceless.
本书共 11 章,分为 4 部分。第 1 部分介绍单元测试,并复习一些更通用的单元测试原则:
The book’s 11 chapters are divided into 4 parts. Part 1 introduces unit testing and gives a refresher on some of the more generic unit testing principles:
第 2 部分深入探讨了该主题的核心 — — 它展示了如何进行良好的单元测试,并提供了有关如何重构测试以使其更有价值的详细信息:
Part 2 gets to the heart of the subject—it shows what makes a good unit test and provides details about how to refactor your tests toward being more valuable:
第 3 部分探讨集成测试的主题:
Part 3 explores the topic of integration testing:
第 4 部分的第 11 章介绍了常见的单元测试反模式,其中一些您可能以前遇到过。
Part 4’s chapter 11 covers common unit testing anti-patterns, some of which you’ve possibly encountered before.
代码示例是用 C# 编写的,但它们所说明的主题适用于任何面向对象的语言,例如 Java 或 C++。C# 恰好是我最常用的语言。
The code samples are written in C#, but the topics they illustrate are applicable to any object-oriented language, such as Java or C++. C# is just the language that I happen to work with the most.
我尽量不使用任何 C# 特有的语言特性,并尽可能简化示例代码,这样您理解起来应该不会有任何困难。您可以在www.manning.com/books/unit-testing上在线下载所有代码示例。
I tried not to use any C#-specific language features, and I made the sample code as simple as possible, so you shouldn’t have any trouble understanding it. You can download all of the code samples online at www.manning.com/books/unit-testing.
购买《单元测试:原则、实践和模式》可免费访问由 Manning Publications 运营的私人网络论坛,您可以在论坛上对本书发表评论、提出技术问题以及获得作者和其他用户的帮助。要访问论坛,请转到https://livebook.manning.com/#!/book/unit-testing/discussion 。您还可以在https://livebook.manning.com/#!/discussion上了解有关 Manning 论坛和行为准则的更多信息。
Purchase of Unit Testing: Principles, Practices, and Patterns includes free access to a private web forum run by Manning Publications where you can make comments about the book, ask technical questions, and receive help from the author and from other users. To access the forum, go to https://livebook.manning.com/#!/book/unit-testing/discussion. You can also learn more about Manning’s forums and the rules of conduct at https://livebook.manning.com/#!/discussion.
Manning 对我们读者的承诺是提供一个平台,让读者之间以及读者和作者之间可以进行有意义的对话。这并不是对作者参与论坛的任何具体次数的承诺,作者对论坛的贡献仍然是自愿的(并且是无偿的)。我们建议您尝试向作者提出一些有挑战性的问题,以免他失去兴趣!只要这本书还在印刷中,就可以从出版商的网站上访问论坛和以前的讨论档案。
Manning’s commitment to our readers is to provide a venue where a meaningful dialogue between individual readers and between readers and the author can take place. It is not a commitment to any specific amount of participation on the part of the author, whose contribution to the forum remains voluntary (and unpaid). We suggest you try asking the author some challenging questions lest his interest stray! The forum and the archives of previous discussions will be accessible from the publisher’s website as long as the book is in print.
V LADIMIR K HORIKOV是一名软件工程师、Microsoft MVP 和 Pluralsight 作者。他从事软件开发工作超过 15 年,包括指导团队了解单元测试的来龙去脉。在过去几年中,Vladimir 撰写了几个受欢迎的博客文章系列和一个关于单元测试主题的在线培训课程。他的教学风格的最大优势,也是学生们经常称赞的,是他倾向于拥有强大的理论背景,然后将其应用于实际案例。
VLADIMIR KHORIKOV is a software engineer, Microsoft MVP, and Pluralsight author. He has been professionally involved in software development for over 15 years, including mentoring teams on the ins and outs of unit testing. During the past several years, Vladimir has written several popular blog post series and an online training course on the topic of unit testing. The biggest advantage of his teaching style, and the one students often praise, is his tendency to have a strong theoretic background, which he then applies to practical examples.
《单元测试:原则、实践和模式》封面上的插图题为“Esthinienne”。插图取自 Jacques Grasset de Saint-Sauveur (1757–1810) 收集的各国服饰,名为Costumes Civils Actuels de Tous les Peuples Connus,于 1788 年在法国出版。每幅插图都经过精心绘制和手工着色。Grasset de Saint-Sauveur 的藏品种类丰富,生动地提醒我们 200 年前世界城镇和地区的文化差异有多么大。人们彼此隔绝,说着不同的方言和语言。在街头或乡村,只需通过他们的着装就可以轻松识别他们住在哪里、从事什么行业或社会地位。
The figure on the cover of Unit Testing: Principles, Practices, and Patterns is captioned “Esthinienne.” The illustration is taken from a collection of dress costumes from various countries by Jacques Grasset de Saint-Sauveur (1757–1810), titled Costumes Civils Actuels de Tous les Peuples Connus, published in France in 1788. Each illustration is finely drawn and colored by hand. The rich variety of Grasset de Saint-Sauveur’s collection reminds us vividly of how culturally apart the world’s towns and regions were just 200 years ago. Isolated from each other, people spoke different dialects and languages. In the streets or in the countryside, it was easy to identify where they lived and what their trade or station in life was just by their dress.
从那时起,我们的穿着方式就发生了变化,当时如此丰富的地区多样性已逐渐消失。现在很难区分不同大陆的居民,更不用说不同城镇、地区或国家的居民了。也许我们已经用文化多样性换取了更加多样化的个人生活——当然是更加多样化和快节奏的科技生活。
The way we dress has changed since then and the diversity by region, so rich at the time, has faded away. It is now hard to tell apart the inhabitants of different continents, let alone different towns, regions, or countries. Perhaps we have traded cultural diversity for a more varied personal life—certainly for a more varied and fast-paced technological life.
在如今这个计算机书籍已经很难区分的时代,曼宁通过以两个世纪前丰富多样的地域生活为基础的书籍封面来赞美计算机行业的创造性和主动性,并通过格拉塞特·德·圣索沃尔的图片将其重新呈现。
At a time when it is hard to tell one computer book from another, Manning celebrates the inventiveness and initiative of the computer business with book covers based on the rich diversity of regional life of two centuries ago, brought back to life by Grasset de Saint-Sauveur’s pictures.
本书的这一部分将帮助您快速了解单元测试的现状。在第 1 章中,我将定义单元测试的目标,并概述如何区分好的测试和坏的测试。我们将讨论覆盖率指标,并讨论良好单元测试的一般属性。
This part of the book will get you up to speed with the current state of unit testing. In chapter 1, I’ll define the goal of unit testing and give an overview of how to differentiate a good test from a bad one. We’ll talk about coverage metrics and discuss properties of a good unit test in general.
在第 2 章中,我们将讨论单元测试的定义。关于这个定义的一个看似微小的分歧导致了两种单元测试学派的形成,我们也将深入探讨这一点。第 3 章将重新介绍一些基本主题,例如单元测试的结构、测试装置的重用和测试参数化。
In chapter 2, we’ll look at the definition of unit test. A seemingly minor disagreement over this definition has led to the formation of two schools of unit testing, which we’ll also dive into. Chapter 3 provides a refresher on some basic topics, such as structuring of unit tests, reusing test fixtures, and test parametrization.
本章涵盖
This chapter covers
学习单元测试并不止于掌握它的技术部分,例如您最喜欢的测试框架、模拟库等等。单元测试不仅仅是编写测试。您必须始终努力实现对单元测试投入时间的最佳回报,最大限度地减少您在测试中投入的精力并最大限度地提高测试带来的好处。实现这两点并非易事。
Learning unit testing doesn’t stop at mastering the technical bits of it, such as your favorite test framework, mocking library, and so on. There’s much more to unit testing than the act of writing tests. You always have to strive to achieve the best return on the time you invest in unit testing, minimizing the effort you put into tests and maximizing the benefits they provide. Achieving both things isn’t an easy task.
看到那些实现了这种平衡的项目真是令人着迷:它们可以毫不费力地发展,不需要太多维护,并且可以快速适应客户不断变化的需求。同样令人沮丧的是,那些未能做到这一点的项目。尽管付出了所有努力并进行了大量的单元测试,但这些项目进展缓慢,存在大量错误和维护成本。
It’s fascinating to watch projects that have achieved this balance: they grow effortlessly, don’t require much maintenance, and can quickly adapt to their customers’ ever-changing needs. It’s equally frustrating to see projects that failed to do so. Despite all the effort and an impressive number of unit tests, such projects drag on slowly, with lots of bugs and upkeep costs.
这就是各种单元测试技术之间的区别。有些技术可以产生很好的结果并有助于保持软件质量。其他技术则不然:它们导致测试没有多大贡献、经常中断并且通常需要大量维护。
That’s the difference between various unit testing techniques. Some yield great outcomes and help maintain software quality. Others don’t: they result in tests that don’t contribute much, break often, and require a lot of maintenance in general.
本书中的内容将帮助您区分好的和坏的单元测试技术。您将学习如何对测试进行成本效益分析,并在特定情况下应用适当的测试技术。您还将学习如何避免常见的反模式 - 这些模式最初可能有意义,但日后却会带来麻烦。
What you learn in this book will help you differentiate between good and bad unit testing techniques. You’ll learn how to do a cost-benefit analysis of your tests and apply proper testing techniques in your particular situation. You’ll also learn how to avoid common anti-patterns—patterns that may make sense at first but lead to trouble down the road.
但是让我们先从基础开始。本章简要概述了软件行业中单元测试的现状,描述了编写和维护测试背后的目标,并为您提供了使测试套件成功的想法。
But let’s start with the basics. This chapter gives a quick overview of the state of unit testing in the software industry, describes the goal behind writing and maintaining tests, and provides you with the idea of what makes a test suite successful.
在过去的二十年里,人们一直在推动采用单元测试。这一努力取得了巨大成功,以至于现在大多数公司都认为单元测试是强制性的。大多数程序员都进行单元测试,并了解其重要性。关于你是否应该这样做,已经没有任何争议了。除非你正在做一个一次性项目,否则答案是,是的,你应该这样做。
For the past two decades, there’s been a push toward adopting unit testing. The push has been so successful that unit testing is now considered mandatory in most companies. Most programmers practice unit testing and understand its importance. There’s no longer any dispute as to whether you should do it. Unless you’re working on a throwaway project, the answer is, yes, you do.
在企业应用程序开发方面,几乎每个项目都至少包含一些单元测试。 此类项目中很大一部分远远超出了这一点:它们通过大量的单元测试和集成测试实现了良好的代码覆盖率。 生产代码和测试代码之间的比率可能在 1:1 到 1:3 之间(对于每行生产代码,有一到三行测试代码)。 有时,这个比率会高得多,达到惊人的 1:10。
When it comes to enterprise application development, almost every project includes at least some unit tests. A significant percentage of such projects go far beyond that: they achieve good code coverage with lots and lots of unit and integration tests. The ratio between the production code and the test code could be anywhere between 1:1 and 1:3 (for each line of production code, there are one to three lines of test code). Sometimes, this ratio goes much higher than that, to a whopping 1:10.
但与所有新技术一样,单元测试也在不断发展。讨论已经从“我们应该编写单元测试吗?”转移到“编写好的单元测试意味着什么?”这仍然是主要的困惑所在。
But as with all new technologies, unit testing continues to evolve. The discussion has shifted from “Should we write unit tests?” to “What does it mean to write good unit tests?” This is where the main confusion still lies.
您可以在软件项目中看到这种混乱的结果。许多项目都有自动化测试;他们甚至可能有很多这样的测试。但这些测试的存在往往不能提供开发人员希望的结果。程序员仍然需要付出很多努力才能在这样的项目中取得进展。新功能需要很长时间才能实现,新的错误不断出现在已经实现和接受的功能中,而本应有所帮助的单元测试似乎根本没有缓解这种情况。它们甚至可能使情况变得更糟。
You can see the results of this confusion in software projects. Many projects have automated tests; they may even have a lot of them. But the existence of those tests often doesn’t provide the results the developers hope for. It can still take programmers a lot of effort to make progress in such projects. New features take forever to implement, new bugs constantly appear in the already implemented and accepted functionality, and the unit tests that are supposed to help don’t seem to mitigate this situation at all. They can even make it worse.
这对于任何人来说都是一个可怕的情况——这是单元测试不能正确完成工作的结果。好测试和坏测试之间的区别不仅仅是品味或个人偏好的问题,而是你正在从事的这个关键项目成功或失败的问题。
It’s a horrible situation for anyone to be in—and it’s the result of having unit tests that don’t do their job properly. The difference between good and bad tests is not merely a matter of taste or personal preference, it’s a matter of succeeding or failing at this critical project you’re working on.
讨论什么是好的单元测试的重要性怎么强调都不为过。不过,在软件开发行业,这种讨论并不多见。今天。你可以在网上找到一些文章和会议演讲,但我还没有看到关于这个主题的任何综合材料。
It’s hard to overestimate the importance of the discussion of what makes a good unit test. Still, this discussion isn’t occurring much in the software development industry today. You’ll find a few articles and conference talks online, but I’ve yet to see any comprehensive material on this topic.
书籍的情况也好不到哪里去;大多数书籍都侧重于单元测试的基础知识,但并没有涉及更多内容。不要误会我的意思。这类书籍很有价值,尤其是当你刚开始进行单元测试时。然而,学习并不止于基础知识。还有下一个层次:不仅仅是编写测试,还要以一种能为你带来最佳回报的方式进行单元测试。当你达到这一点时,大多数书籍几乎都让你自己想办法进入下一个层次。
The situation in books isn’t any better; most of them focus on the basics of unit testing but don’t go much beyond that. Don’t get me wrong. There’s a lot of value in such books, especially when you are just starting out with unit testing. However, the learning doesn’t end with the basics. There’s a next level: not just writing tests, but doing unit testing in a way that provides you with the best return on your efforts. When you reach this point, most books pretty much leave you to your own devices to figure out how to get to that next level.
这本书将带你到达那里。它教授了理想单元测试的精确、科学的定义。你将看到如何将这个定义应用于实际的现实世界示例。我希望这本书能帮助你理解为什么你的特定项目尽管进行了大量测试,但仍然可能出现问题,以及如何纠正其进程以使其变得更好。
This book takes you there. It teaches a precise, scientific definition of the ideal unit test. You’ll see how this definition can be applied to practical, real-world examples. My hope is that this book will help you understand why your particular project may have gone sideways despite having a good number of tests, and how to correct its course for the better.
如果您从事企业应用程序开发工作,您将从本书中获得最大的价值,但核心思想适用于任何软件项目。
You’ll get the most value out of this book if you work in enterprise application development, but the core ideas are applicable to any software project.
企业应用程序是旨在自动化或协助组织内部流程的应用程序。它可以有多种形式,但通常企业软件的特征是
An enterprise application is an application that aims at automating or assisting an organization’s inner processes. It can take many forms, but usually the characteristics of an enterprise software are
在深入探讨单元测试这个话题之前,让我们先退一步,考虑一下单元测试能帮你实现的目标。人们常说,单元测试实践会带来更好的设计。这是真的:为代码库编写单元测试的必要性通常会带来更好的设计。但这不是单元测试的主要目标;它只是一个令人愉快的副作用。
Before taking a deep dive into the topic of unit testing, let’s step back and consider the goal that unit testing helps you to achieve. It’s often said that unit testing practices lead to a better design. And it’s true: the necessity to write unit tests for a code base normally leads to a better design. But that’s not the main goal of unit testing; it’s merely a pleasant side effect.
对一段代码进行单元测试的能力是一个很好的试金石,但它只能在一个方向上起作用。这是一个很好的负面指标——它以相对较高的准确度指出了质量较差的代码。如果你发现代码很难进行单元测试,这是一个强烈的信号,表明代码需要改进。质量差通常表现为紧密耦合,这意味着生产代码的不同部分彼此之间没有足够解耦,很难单独测试它们。
The ability to unit test a piece of code is a nice litmus test, but it only works in one direction. It’s a good negative indicator—it points out poor-quality code with relatively high accuracy. If you find that code is hard to unit test, it’s a strong sign that the code needs improvement. The poor quality usually manifests itself in tight coupling, which means different pieces of production code are not decoupled from each other enough, and it’s hard to test them separately.
不幸的是,对一段代码进行单元测试的能力并不是一个好的积极指标。你可以轻松地对代码库进行单元测试并不一定意味着它的质量很好。即使项目表现出高度的解耦,它也可能是一个灾难。
Unfortunately, the ability to unit test a piece of code is a bad positive indicator. The fact that you can easily unit test your code base doesn’t necessarily mean it’s of good quality. The project can be a disaster even when it exhibits a high degree of decoupling.
那么,单元测试的目标是什么?目标是实现软件项目的可持续增长。可持续一词是关键。发展一个项目相当容易,尤其是当你从头开始时。但长期维持这种增长则困难得多。
What is the goal of unit testing, then? The goal is to enable sustainable growth of the software project. The term sustainable is key. It’s quite easy to grow a project, especially when you start from scratch. It’s much harder to sustain this growth over time.
图 1.1显示了典型无测试项目的增长动态。由于没有任何东西拖累你,所以你起步很快。还没有做出任何糟糕的架构决策,也没有任何现有代码需要担心。然而,随着时间的推移,你必须投入越来越多的时间才能取得与开始时相同的进展。最终,开发速度会显著减慢,有时甚至到了你无法取得任何进展的地步。
Figure 1.1 shows the growth dynamic of a typical project without tests. You start off quickly because there’s nothing dragging you down. No bad architectural decisions have been made yet, and there isn’t any existing code to worry about. As time goes by, however, you have to put in more and more hours to make the same amount of progress you showed at the beginning. Eventually, the development speed slows down significantly, sometimes even to the point where you can’t make any progress whatsoever.
这种开发速度快速下降的现象也称为软件熵。熵(系统中的无序程度)是一个数学和科学概念,也适用于软件系统。(如果您对熵的数学和科学感兴趣,请查阅热力学第二定律。)
This phenomenon of quickly decreasing development speed is also known as software entropy. Entropy (the amount of disorder in a system) is a mathematical and scientific concept that can also apply to software systems. (If you’re interested in the math and science of entropy, look up the second law of thermodynamics.)
在软件中,熵以代码的形式表现出来,代码往往会恶化。每次你改变代码库中的某些内容时,其中的无序程度或熵就会增加。如果没有适当的照顾,比如不断清理和重构,系统就会变得越来越复杂和混乱。修复一个错误会引入更多的错误,修改软件的一个部分会破坏其他几个部分——就像一个多米诺骨牌效应。最终,代码库变得不可靠。最糟糕的是,很难恢复稳定。
In software, entropy manifests in the form of code that tends to deteriorate. Each time you change something in a code base, the amount of disorder in it, or entropy, increases. If left without proper care, such as constant cleaning and refactoring, the system becomes increasingly complex and disorganized. Fixing one bug introduces more bugs, and modifying one part of the software breaks several others—it’s like a domino effect. Eventually, the code base becomes unreliable. And worst of all, it’s hard to bring it back to stability.
测试有助于扭转这种趋势。它们充当安全网——一种可以防止绝大多数回归的工具。测试有助于确保现有功能正常运行,即使在您引入新功能或重构代码以更好地满足新要求之后也是如此。
Tests help overturn this tendency. They act as a safety net—a tool that provides insurance against a vast majority of regressions. Tests help make sure the existing functionality works, even after you introduce new features or refactor the code to better fit new requirements.
回归是指某个功能在发生特定事件(通常是代码修改)后不再按预期运行。回归和软件错误是同义词,可以互换使用。
A regression is when a feature stops working as intended after a certain event (usually, a code modification). The terms regression and software bug are synonyms and can be used interchangeably.
缺点是测试需要付出初期的努力,有时甚至需要付出很大努力。但从长远来看,测试有助于项目在后期发展,从而带来回报。如果没有测试不断验证代码库,软件开发根本无法扩展。
The downside here is that tests require initial—sometimes significant—effort. But they pay for themselves in the long run by helping the project to grow in the later stages. Software development without the help of tests that constantly verify the code base simply doesn’t scale.
可持续性和可扩展性是关键。它们能让你长期保持开发速度。
Sustainability and scalability are the keys. They allow you to maintain development speed in the long run.
尽管单元测试有助于保持项目的增长,但仅仅编写测试是不够的。编写糟糕的测试仍然会导致同样的结果。
Although unit testing helps maintain project growth, it’s not enough to just write tests. Badly written tests still result in the same picture.
如图 1.2所示,糟糕的测试确实有助于在开始时减缓代码恶化:与完全没有测试的情况相比,开发速度的下降并不那么明显。但从总体上看,情况并没有真正改变。这样的项目可能需要更长的时间才能进入停滞阶段,但停滞仍然是不可避免的。
As shown in figure 1.2, bad tests do help to slow down code deterioration at the beginning: the decline in development speed is less prominent compared to the situation with no tests at all. But nothing really changes in the grand scheme of things. It might take longer for such a project to enter the stagnation phase, but stagnation is still inevitable.
请记住,并非所有测试都是平等的。其中一些很有价值,对整体软件质量有很大贡献。其他则不然。它们会发出错误警报,无法帮助您捕获回归错误,并且速度很慢且难以维护。很容易陷入为了单元测试而编写单元测试的陷阱,而不清楚它是否有助于项目。
Remember, not all tests are created equal. Some of them are valuable and contribute a lot to overall software quality. Others don’t. They raise false alarms, don’t help you catch regression errors, and are slow and difficult to maintain. It’s easy to fall into the trap of writing unit tests for the sake of unit testing without a clear picture of whether it helps the project.
仅仅在项目中投入更多测试并不能实现单元测试的目标。您需要考虑测试的价值及其维护成本。成本部分取决于在各种活动上花费的时间:
You can’t achieve the goal of unit testing by just throwing more tests at the project. You need to consider both the test’s value and its upkeep cost. The cost component is determined by the amount of time spent on various activities:
由于维护成本高,很容易创建净值接近于零甚至为负值的测试。为了实现可持续的项目增长,您必须专注于高质量的测试——这是测试套件中唯一值得保留的测试类型。
It’s easy to create tests whose net value is close to zero or even is negative due to high maintenance costs. To enable sustainable project growth, you have to exclusively focus on high-quality tests—those are the only type of tests that are worth keeping in the test suite.
人们通常认为生产代码和测试代码是不同的。测试被认为是生产代码的补充,没有所有权成本。因此,人们通常认为测试越多越好。事实并非如此。代码是一种负债,而不是资产。您引入的代码越多,软件中潜在错误的覆盖范围就越大,项目的维护成本就越高。用尽可能少的代码解决问题总是更好的。
People often think production code and test code are different. Tests are assumed to be an addition to production code and have no cost of ownership. By extension, people often believe that the more tests, the better. This isn’t the case. Code is a liability, not an asset. The more code you introduce, the more you extend the surface area for potential bugs in your software, and the higher the project’s upkeep cost. It’s always better to solve problems with as little code as possible.
测试也是代码。您应该将它们视为代码库的一部分,旨在解决特定问题:确保应用程序的正确性。单元测试与任何其他代码一样,也容易出现错误并需要维护。
Tests are code, too. You should view them as the part of your code base that aims at solving a particular problem: ensuring the application’s correctness. Unit tests, just like any other code, are also vulnerable to bugs and require maintenance.
学会如何区分好的和坏的单元测试至关重要。我在第 4 章中介绍了这个主题。
It’s crucial to learn how to differentiate between good and bad unit tests. I cover this topic in chapter 4.
在本节中,我将讨论两种最流行的覆盖率指标——代码覆盖率和分支覆盖率——如何计算它们、如何使用它们以及它们存在的问题。我将说明为什么程序员瞄准特定的覆盖率数字是有害的,以及为什么不能仅仅依靠覆盖率指标来确定测试套件的质量。
In this section, I talk about the two most popular coverage metrics—code coverage and branch coverage—how to calculate them, how they’re used, and problems with them. I’ll show why it’s detrimental for programmers to aim at a particular coverage number and why you can’t just rely on coverage metrics to determine the quality of your test suite.
覆盖率指标显示测试套件执行的源代码量,从无到 100%。
A coverage metric shows how much source code a test suite executes, from none to 100%.
覆盖率指标有多种类型,通常用于评估测试套件的质量。普遍的看法是覆盖率越高越好。
There are different types of coverage metrics, and they’re often used to assess the quality of a test suite. The common belief is that the higher the coverage number, the better.
不幸的是,事情没那么简单,覆盖率指标虽然提供了有价值的反馈,但不能用来有效地衡量测试套件的质量。这与单元测试代码的能力的情况相同:覆盖率指标是一个很好的负面指标,但却是一个糟糕的正面指标。
Unfortunately, it’s not that simple, and coverage metrics, while providing valuable feedback, can’t be used to effectively measure the quality of a test suite. It’s the same situation as with the ability to unit test the code: coverage metrics are a good negative indicator but a bad positive one.
如果指标显示代码库的覆盖率太低(例如只有 10%),这说明您的测试不够充分。但反之则不然:即使覆盖率达到 100%,也不能保证测试套件的质量。覆盖率高的测试套件质量可能仍然很差。
If a metric shows that there’s too little coverage in your code base—say, only 10%—that’s a good indication that you are not testing enough. But the reverse isn’t true: even 100% coverage isn’t a guarantee that you have a good-quality test suite. A test suite that provides high coverage can still be of poor quality.
我已经提到了为什么会这样——你不能只是对项目进行随机测试,希望这些测试能改善情况。但让我们从代码覆盖率指标的角度详细讨论这个问题。
I already touched on why this is so—you can’t just throw random tests at your project with the hope those tests will improve the situation. But let’s discuss this problem in detail with respect to the code coverage metric.
第一个也是最常用的覆盖率指标是代码覆盖率,也称为测试覆盖率;见图1.3。此指标显示至少一个测试执行的代码行数与生产代码库中的总行数之比。
The first and most-used coverage metric is code coverage, also known as test coverage; see figure 1.3. This metric shows the ratio of the number of code lines executed by at least one test and the total number of lines in the production code base.
让我们看一个例子来更好地理解它是如何工作的。清单 1.1展示了一个IsStringLong方法和一个覆盖它的测试。该方法确定作为输入参数提供给它的字符串是否为长字符串(这里,长字符串的定义是长度大于 5 个字符的任何字符串)。测试使用该方法进行测试"abc",并检查该字符串是否不被视为长字符串。
Let’s see an example to better understand how this works. Listing 1.1 shows an IsStringLong method and a test that covers it. The method determines whether a string provided to it as an input parameter is long (here, the definition of long is any string with the length greater than five characters). The test exercises the method using "abc" and checks that this string is not considered long.
公共静态 bool IsStringLong(字符串输入)
{ 1
if (输入.Length > 5) 1
返回 true; 2
返回 false; 1
} 1
公共无效测试()
{
bool 结果 = IsStringLong("abc");
断言.等于(false,结果);
}public static bool IsStringLong(string input)
{ 1
if (input.Length > 5) 1
return true; 2
return false; 1
} 1
public void Test()
{
bool result = IsStringLong("abc");
Assert.Equal(false, result);
}
这里很容易计算代码覆盖率。方法中的总行数为五行(花括号也算在内)。测试执行的行数为四行——测试遍历除return true;语句之外的所有代码行。这给了我们 4/5 = 0.8 = 80% 的代码覆盖率。
It’s easy to calculate the code coverage here. The total number of lines in the method is five (curly braces count, too). The number of lines executed by the test is four—the test goes through all the code lines except for the return true; statement. This gives us 4/5 = 0.8 = 80% code coverage.
现在,如果我重构该方法并内联不必要的if语句,就像这样,会怎么样?
Now, what if I refactor the method and inline the unnecessary if statement, like this?
公共静态 bool IsStringLong(字符串输入)
{
返回输入.Length > 5 ? true : false;
}
公共无效测试()
{
bool 结果 = IsStringLong("abc");
断言.等于(false,结果);
}public static bool IsStringLong(string input)
{
return input.Length > 5 ? true : false;
}
public void Test()
{
bool result = IsStringLong("abc");
Assert.Equal(false, result);
}
代码覆盖率数字会改变吗?是的,会。由于测试现在执行所有三行代码(语句return加两个花括号),因此代码覆盖率增加到 100%。
Does the code coverage number change? Yes, it does. Because the test now exercises all three lines of code (the return statement plus two curly braces), the code coverage increases to 100%.
但是我通过这次重构改进了测试套件吗?当然没有。我只是对方法内的代码进行了调整。测试仍然验证了相同数量的可能结果。
But did I improve the test suite with this refactoring? Of course not. I just shuffled the code inside the method. The test still verifies the same number of possible outcomes.
这个简单的例子表明,玩弄覆盖率数字是多么容易。代码越紧凑,测试覆盖率指标就越好,因为它只考虑原始行数。同时,将更多代码压缩到更少的空间不会(也不应该)改变测试套件的价值或底层代码库的可维护性。
This simple example shows how easy it is to game the coverage numbers. The more compact your code is, the better the test coverage metric becomes, because it only accounts for the raw line numbers. At the same time, squashing more code into less space doesn’t (and shouldn’t) change the value of the test suite or the maintainability of the underlying code base.
另一个覆盖率指标称为分支覆盖率。分支覆盖率比代码覆盖率提供更精确的结果,因为它有助于弥补代码覆盖率的不足。该指标不使用原始代码行数,而是关注控制结构,例如if和switch语句。它显示套件中至少一个测试遍历了多少个这样的控制结构,如图 1.4所示。
Another coverage metric is called branch coverage. Branch coverage provides more precise results than code coverage because it helps cope with code coverage’s shortcomings. Instead of using the raw number of code lines, this metric focuses on control structures, such as if and switch statements. It shows how many of such control structures are traversed by at least one test in the suite, as shown in figure 1.4.
要计算分支覆盖率指标,您需要汇总代码库中的所有可能分支,并查看测试访问了其中多少个分支。让我们再次以前面的例子为例:
To calculate the branch coverage metric, you need to sum up all possible branches in your code base and see how many of them are visited by tests. Let’s take our previous example again:
公共静态 bool IsStringLong(字符串输入)
{
返回输入.Length > 5 ? true : false;
}
公共无效测试()
{
bool 结果 = IsStringLong("abc");
断言.等于(false,结果);
}public static bool IsStringLong(string input)
{
return input.Length > 5 ? true : false;
}
public void Test()
{
bool result = IsStringLong("abc");
Assert.Equal(false, result);
}
该方法有两个分支IsStringLong:一个用于字符串参数的长度大于五个字符的情况,另一个用于字符串参数的长度不大于五个字符的情况。测试仅涵盖其中一个分支,因此分支覆盖率指标为 1/2 = 0.5 = 50%。我们如何表示测试代码并不重要 — 无论我们是if像以前一样使用语句还是使用较短的表示法。分支覆盖率指标仅考虑分支的数量;它不考虑实现这些分支需要多少行代码。
There are two branches in the IsStringLong method: one for the situation when the length of the string argument is greater than five characters, and the other one when it’s not. The test covers only one of these branches, so the branch coverage metric is 1/2 = 0.5 = 50%. And it doesn’t matter how we represent the code under test—whether we use an if statement as before or use the shorter notation. The branch coverage metric only accounts for the number of branches; it doesn’t take into consideration how many lines of code it took to implement those branches.
图 1.5展示了一种可视化此指标的有用方法。您可以将测试代码可以采取的所有可能路径表示为一个图,并查看其中有多少已被遍历。IsStringLong有两条这样的路径,而测试只执行其中一条。
Figure 1.5 shows a helpful way to visualize this metric. You can represent all possible paths the code under test can take as a graph and see how many of them have been traversed. IsStringLong has two such paths, and the test exercises only one of them.
尽管分支覆盖率指标比代码覆盖率产生更好的结果,但您仍然不能依赖它们中的任何一个来确定测试套件的质量,原因有二:
Although the branch coverage metric yields better results than code coverage, you still can’t rely on either of them to determine the quality of your test suite, for two reasons:
让我们更仔细地看看每一个原因。
Let’s look more closely at each of these reasons.
为了使代码路径得到实际测试而不仅仅是执行,您的单元测试必须具有适当的断言。换句话说,您需要检查被测系统产生的结果是否正是您期望它产生的准确结果。此外,这个结果可能有几个组成部分;为了使覆盖率指标有意义,您需要验证所有这些指标。
For the code paths to be actually tested and not just exercised, your unit tests must have appropriate assertions. In other words, you need to check that the outcome the system under test produces is the exact outcome you expect it to produce. Moreover, this outcome may have several components; and for the coverage metrics to be meaningful, you need to verify all of them.
下一个清单展示了该IsStringLong方法的另一个版本。它将最后的结果记录到公共WasLastStringLong属性中。
The next listing shows another version of the IsStringLong method. It records the last result into a public WasLastStringLong property.
公共静态 bool WasLastStringLong { 获取; 私人设置; }
公共静态 bool IsStringLong(字符串输入)
{
bool 结果 = 输入.Length > 5 ? true : false;
WasLastStringLong = 结果; 1
返回结果; 2
}
公共无效测试()
{
bool 结果 = IsStringLong("abc");
断言.Equal(false,结果); 3
}public static bool WasLastStringLong { get; private set; }
public static bool IsStringLong(string input)
{
bool result = input.Length > 5 ? true : false;
WasLastStringLong = result; 1
return result; 2
}
public void Test()
{
bool result = IsStringLong("abc");
Assert.Equal(false, result); 3
}
该IsStringLong方法现在有两个结果:一个显式结果,由返回值编码;另一个隐式结果,即属性的新值。尽管没有验证第二个隐式结果,覆盖率指标仍会显示相同的结果:代码覆盖率为 100%,分支覆盖率为 50%。如您所见,覆盖率指标并不能保证底层代码经过测试,只能保证它在某个时刻已经执行过。
The IsStringLong method now has two outcomes: an explicit one, which is encoded by the return value; and an implicit one, which is the new value of the property. And in spite of not verifying the second, implicit outcome, the coverage metrics would still show the same results: 100% for the code coverage and 50% for the branch coverage. As you can see, the coverage metrics don’t guarantee that the underlying code is tested, only that it has been executed at some point.
这种情况下,结果测试部分结束的一种极端情况是无断言测试,即编写的测试中完全不包含任何断言语句。以下是无断言测试的一个例子。
An extreme version of this situation with partially tested outcomes is assertion-free testing, which is when you write tests that don’t have any assertion statements in them whatsoever. Here’s an example of assertion-free testing.
公共无效测试()
{
bool 结果 1 = IsStringLong("abc"); 1
bool 结果 2 = IsStringLong("abcdef"); 2
}public void Test()
{
bool result1 = IsStringLong("abc"); 1
bool result2 = IsStringLong("abcdef"); 2
}
该测试的代码和分支覆盖率指标均为 100%。但与此同时,它完全没用,因为它没有验证任何内容。
This test has both code and branch coverage metrics showing 100%. But at the same time, it is completely useless because it doesn’t verify anything.
无断言测试的概念可能看起来像一个愚蠢的想法,但它确实在现实中发生。
The concept of assertion-free testing might look like a dumb idea, but it does happen in the wild.
几年前,我曾参与过一个项目,管理层对每个正在开发的项目都提出了严格的要求,即代码覆盖率必须达到 100%。这一举措的初衷是崇高的。当时,单元测试还没有像今天这样流行。组织中很少有人实践单元测试,持续进行单元测试的人就更少了。
Years ago, I worked on a project where management imposed a strict requirement of having 100% code coverage for every project under development. This initiative had noble intentions. It was during the time when unit testing wasn’t as prevalent as it is today. Few people in the organization practiced it, and even fewer did unit testing consistently.
一群开发人员参加了一个会议,会上许多讨论都与单元测试有关。回来后,他们决定将新知识付诸实践。高层管理人员支持他们,并开始了向更好编程技术的重大转变。他们进行了内部演示。安装了新工具。更重要的是,公司范围内实施了一项新规则:所有开发团队都必须专注于编写测试,直到达到 100% 的代码覆盖率。达到此目标后,任何降低指标的代码签入都必须被构建系统拒绝。
A group of developers had gone to a conference where many talks were devoted to unit testing. After returning, they decided to put their new knowledge into practice. Upper management supported them, and the great conversion to better programming techniques began. Internal presentations were given. New tools were installed. And, more importantly, a new company-wide rule was imposed: all development teams had to focus on writing tests exclusively until they reached the 100% code coverage mark. After they reached this goal, any code check-in that lowered the metric had to be rejected by the build systems.
你可能猜到了,结果并不好。被这种严重的限制压垮了,开发人员开始寻找方法来玩弄系统。自然,他们中的许多人都意识到了这一点:如果你用try/catch块包装所有测试,并且不在其中引入任何断言,那么这些测试就一定会通过。人们开始盲目地创建测试,以满足强制性的 100% 覆盖率要求。不用说,这些测试并没有给项目带来任何价值。此外,他们还损害了项目,因为他们把所有的精力和时间都从生产活动中转移了出去,也因为维持测试向前发展所需的维护成本。
As you might guess, this didn’t play out well. Crushed by this severe limitation, developers started to seek ways to game the system. Naturally, many of them came to the same realization: if you wrap all tests with try/catch blocks and don’t introduce any assertions in them, those tests are guaranteed to pass. People started to mindlessly create tests for the sake of meeting the mandatory 100% coverage requirement. Needless to say, those tests didn’t add any value to the projects. Moreover, they damaged the projects because of all the effort and time they steered away from productive activities, and because of the upkeep costs required to maintain the tests moving forward.
最终,该要求降低到 90%,然后又降低到 80%;一段时间后,它被完全撤回(变得更好了!)。
Eventually, the requirement was lowered to 90% and then to 80%; after some period of time, it was retracted altogether (for the better!).
但是,假设您彻底验证了测试代码的每个结果。这与分支覆盖率指标相结合,是否提供了一种可靠的机制,您可以使用该机制来确定测试套件的质量?不幸的是,不是。
But let’s say that you thoroughly verify each outcome of the code under test. Does this, in combination with the branch coverage metric, provide a reliable mechanism, which you can use to determine the quality of your test suite? Unfortunately, no.
所有覆盖率指标的第二个问题是,它们没有考虑被测系统调用外部库的方法时所经过的代码路径。让我们来看以下示例:
The second problem with all coverage metrics is that they don’t take into account code paths that external libraries go through when the system under test calls methods on them. Let’s take the following example:
公共静态int Parse(字符串输入)
{
返回 int.Parse(输入);
}
公共无效测试()
{
int 结果 = 解析(“5”);
断言.等于(5,结果);
}public static int Parse(string input)
{
return int.Parse(input);
}
public void Test()
{
int result = Parse("5");
Assert.Equal(5, result);
}
分支覆盖率指标显示为 100%,测试验证了方法结果的所有组成部分。它只有一个这样的组成部分——返回值。同时,这个测试远非详尽无遗。它没有考虑 .NET Framework 方法可能经过的代码路径。即使在这个简单的方法中,也有相当多的代码路径,如图 1.6int.Parse所示。
The branch coverage metric shows 100%, and the test verifies all components of the method’s outcome. It has a single such component anyway—the return value. At the same time, this test is nowhere near being exhaustive. It doesn’t take into account the code paths the .NET Framework’s int.Parse method may go through. And there are quite a number of code paths, even in this simple method, as you can see in figure 1.6.
内置integer类型有很多隐藏在测试之外的分支,如果您更改方法的输入参数,可能会导致不同的结果。以下只是一些无法转换为整数的可能参数:
The built-in integer type has plenty of branches that are hidden from the test and that might lead to different results, should you change the method’s input parameter. Here are just a few possible arguments that can’t be transformed into an integer:
您可能会遇到许多极端情况,并且无法确定您的测试是否能够解决所有问题。
You can fall into numerous edge cases, and there’s no way to see if your tests account for all of them.
这并不是说覆盖率指标应该考虑外部库中的代码路径(它们不应该),而是告诉你不能依赖这些指标来判断单元测试的好坏。覆盖率指标不可能判断你的测试是否详尽;也不能说明你的测试是否足够。
This is not to say that coverage metrics should take into account code paths in external libraries (they shouldn’t), but rather to show you that you can’t rely on those metrics to see how good or bad your unit tests are. Coverage metrics can’t possibly tell whether your tests are exhaustive; nor can they say if you have enough tests.
至此,我希望您能够明白,仅依靠覆盖率指标来确定测试套件的质量是不够的。如果您开始将特定的覆盖率数字作为目标,无论是 100%、90% 还是中等的 70%,这也可能导致危险境地。查看覆盖率指标的最佳方式是将其视为指标,而不是目标本身。
At this point, I hope you can see that relying on coverage metrics to determine the quality of your test suite is not enough. It can also lead to dangerous territory if you start making a specific coverage number a target, be it 100%, 90%, or even a moderate 70%. The best way to view a coverage metric is as an indicator, not a goal in and of itself.
想象一下医院里的病人。他们的高体温可能表明发烧,这是一个有用的观察结果。但医院不应该不惜一切代价,把病人的体温控制在合适的范围内。否则,医院可能会采取快速而“有效”的解决方案,在病人旁边安装空调,通过调节吹到他们皮肤上的冷空气量来调节他们的体温。当然,这种方法没有任何意义。
Think of a patient in a hospital. Their high temperature might indicate a fever and is a helpful observation. But the hospital shouldn’t make the proper temperature of this patient a goal to target by any means necessary. Otherwise, the hospital might end up with the quick and “efficient” solution of installing an air conditioner next to the patient and regulating their temperature by adjusting the amount of cold air flowing onto their skin. Of course, this approach doesn’t make any sense.
同样,设定特定的覆盖率数字会产生一种与单元测试目标相悖的不良动机。人们不再专注于测试重要的事情,而是开始寻找实现这一人为目标的方法。正确的单元测试已经够难的了。强制设定覆盖率数字只会分散开发人员对他们所测试内容的注意力,并使正确的单元测试更难实现。
Likewise, targeting a specific coverage number creates a perverse incentive that goes against the goal of unit testing. Instead of focusing on testing the things that matter, people start to seek ways to attain this artificial target. Proper unit testing is difficult enough already. Imposing a mandatory coverage number only distracts developers from being mindful about what they test, and makes proper unit testing even harder to achieve.
对系统核心部分进行高水平覆盖是好事。但将这种高水平作为一项要求是不好的。两者之间的区别虽然微妙,但至关重要。
It’s good to have a high level of coverage in core parts of your system. It’s bad to make this high level a requirement. The difference is subtle but critical.
让我重复一遍:覆盖率指标是一个很好的负面指标,但却是一个不好的正面指标。低覆盖率数字(例如,低于 60%)肯定是麻烦的征兆。这意味着您的代码库中有很多未经测试的代码。但高数字并不意味着什么。因此,测量代码覆盖率应该只是迈向高质量测试套件的第一步。
Let me repeat myself: coverage metrics are a good negative indicator, but a bad positive one. Low coverage numbers—say, below 60%—are a certain sign of trouble. They mean there’s a lot of untested code in your code base. But high numbers don’t mean anything. Thus, measuring the code coverage should be only a first step on the way to a quality test suite.
本章的大部分内容都是在讨论衡量测试套件质量的不正确方法:使用覆盖率指标。那么正确的方法是什么呢?您应该如何衡量测试套件的质量?唯一可靠的方法是逐个评估套件中的每个测试。当然,您不必一次评估所有测试一次;这可能是一项相当大的任务,需要大量的前期工作。您可以逐步进行此评估。关键是没有自动方法可以查看您的测试套件有多好。您必须运用您的个人判断。
I’ve spent most of this chapter discussing improper ways to measure the quality of a test suite: using coverage metrics. What about a proper way? How should you measure your test suite’s quality? The only reliable way is to evaluate each test in the suite individually, one by one. Of course, you don’t have to evaluate all of them at once; that could be quite a large undertaking and require significant upfront effort. You can perform this evaluation gradually. The point is that there’s no automated way to see how good your test suite is. You have to apply your personal judgment.
让我们更全面地了解一下什么因素使得整个测试套件成功。(我们将在第 4 章中深入探讨区分好测试和坏测试的具体方法。)成功的测试套件具有以下属性:
Let’s look at a broader picture of what makes a test suite successful as a whole. (We’ll dive into the specifics of differentiating between good and bad tests in chapter 4.) A successful test suite has the following properties:
自动化测试的唯一意义在于你是否经常使用它们。所有测试都应集成到开发周期中。理想情况下,你应该在每次代码更改时执行它们,即使是最小的更改。
The only point in having automated tests is if you constantly use them. All tests should be integrated into the development cycle. Ideally, you should execute them on every code change, even the smallest one.
正如并非所有测试都一样,在单元测试方面,并非代码库的所有部分都值得同等关注。测试提供的价值不仅在于测试本身的结构,还在于它们验证的代码。
Just as all tests are not created equal, not all parts of your code base are worth the same attention in terms of unit testing. The value the tests provide is not only in how those tests themselves are structured, but also in the code they verify.
重要的是将单元测试工作重点放在系统中最关键的部分,而对其他部分进行简短或间接的验证。在大多数应用程序中,最重要的部分是包含业务逻辑的部分 -域模型。[ 1 ]测试业务逻辑可让您获得最佳的时间投资回报。
It’s important to direct your unit testing efforts to the most critical parts of the system and verify the others only briefly or indirectly. In most applications, the most important part is the part that contains business logic—the domain model.[1] Testing business logic gives you the best return on your time investment.
请参阅Eric Evans 所著的《领域驱动设计:解决软件核心的复杂性》(Addison-Wesley,2003 年)。
See Domain-Driven Design: Tackling Complexity in the Heart of Software by Eric Evans (Addison-Wesley, 2003).
其余所有部分可分为三类:
All other parts can be divided into three categories:
不过,其他部分中的一些可能仍需要进行彻底的单元测试。例如,基础架构代码可能包含复杂且重要的算法,因此对它们进行大量测试也是有意义的。但一般来说,你的大部分注意力应该放在领域模型上。
Some of these other parts may still need thorough unit testing, though. For example, the infrastructure code may contain complex and important algorithms, so it would make sense to cover them with a lot of tests, too. But in general, most of your attention should be spent on the domain model.
某些测试(例如集成测试)可以超越领域模型,并验证整个系统的工作方式,包括代码库的非关键部分。这很好。但重点应该放在领域模型上。
Some of your tests, such as integration tests, can go beyond the domain model and verify how the system works as a whole, including the noncritical parts of the code base. And that’s fine. But the focus should remain on the domain model.
请注意,为了遵循此准则,您应该将域模型与代码库的非必要部分隔离开来。您必须将域模型与所有其他应用程序问题分开,以便您可以专注于单元测试专注于该领域模型的努力。我们将在本书的第 2 部分详细讨论这一切。
Note that in order to follow this guideline, you should isolate the domain model from the non-essential parts of the code base. You have to keep the domain model separated from all other application concerns so you can focus your unit testing efforts on that domain model exclusively. We talk about all this in detail in part 2 of the book.
单元测试最困难的部分是以最小的维护成本实现最大价值。这是本书的重点。
The most difficult part of unit testing is achieving maximum value with minimum maintenance costs. That’s the main focus of this book.
仅仅将测试纳入构建系统是不够的,仅仅保持领域模型的高测试覆盖率也是不够的。同样重要的是,只保留那些价值远远超过维护成本的测试。
It’s not enough to incorporate tests into a build system, and it’s not enough to maintain high test coverage of the domain model. It’s also crucial to keep in the suite only the tests whose value exceeds their upkeep costs by a good margin.
最后一个属性可以分为两部分:
This last attribute can be divided in two:
虽然这些技能看似相似,但本质上却不同。要识别高价值的测试,您需要一个参考框架。另一方面,编写有价值的测试还需要您了解代码设计技术。单元测试和底层代码紧密相连,如果不对它们所涵盖的代码库投入大量精力,就不可能创建有价值的测试。
Although these skills may seem similar, they’re different by nature. To recognize a test of high value, you need a frame of reference. On the other hand, writing a valuable test requires you to also know code design techniques. Unit tests and the underlying code are highly intertwined, and it’s impossible to create valuable tests without putting significant effort into the code base they cover.
你可以把它看作是识别一首好歌和能够创作一首好歌之间的区别。成为作曲家所需的努力与区分好音乐和坏音乐所需的努力不对称地大。单元测试也是如此。编写新测试比检查现有测试需要更多的努力,主要是因为你不是在真空中编写测试:你必须考虑底层代码。因此,尽管我专注于单元测试,但我也将本书的很大一部分用于讨论代码设计。
You can view it as the difference between recognizing a good song and being able to compose one. The amount of effort required to become a composer is asymmetrically larger than the effort required to differentiate between good and bad music. The same is true for unit tests. Writing a new test requires more effort than examining an existing one, mostly because you don’t write tests in a vacuum: you have to take into account the underlying code. And so although I focus on unit tests, I also devote a significant portion of this book to discussing code design.
本书将教您一个参考框架,您可以使用它来分析测试套件中的任何测试。这个参考框架是基础。学习之后,您将能够以全新的眼光看待许多测试,并了解其中哪些测试对项目有贡献,哪些测试必须重构或完全删除。
This book teaches a frame of reference that you can use to analyze any test in your test suite. This frame of reference is foundational. After learning it, you’ll be able to look at many of your tests in a new light and see which of them contribute to the project and which must be refactored or gotten rid of altogether.
在设定好这个阶段(第 4 章)之后,本书将分析现有的单元测试技术和实践(第 4至6章和第 7 章的一部分)。您是否熟悉这些技术和实践并不重要。如果您熟悉它们,您将从新的角度看待它们。最有可能的是,您已经在直观层面上了解了它们。本书可以帮助您阐明为什么您一直在使用的技术和最佳实践如此有用。
After setting this stage (chapter 4), the book analyzes the existing unit testing techniques and practices (chapters 4–6, and part of 7). It doesn’t matter whether you’re familiar with those techniques and practices. If you are familiar with them, you’ll see them from a new angle. Most likely, you already get them at the intuitive level. This book can help you articulate why the techniques and best practices you’ve been using all along are so helpful.
不要低估这项技能。能够清楚地向同事传达你的想法是无价的。如果软件开发人员(即使是优秀的软件开发人员)无法解释做出该决定的确切原因,他们很少会因设计决策而获得全部荣誉。这本书可以帮助你将知识从潜意识领域转变为你可以与任何人谈论的东西。
Don’t underestimate this skill. The ability to clearly communicate your ideas to colleagues is priceless. A software developer—even a great one—rarely gets full credit for a design decision if they can’t explain why, exactly, that decision was made. This book can help you transform your knowledge from the realm of the unconscious to something you are able to talk about with anyone.
如果你对单元测试技术和最佳实践没有太多经验,那么你将学到很多东西。除了可以用来分析测试套件中任何测试的参考框架外,本书还教授
If you don’t have much experience with unit testing techniques and best practices, you’ll learn a lot. In addition to the frame of reference that you can use to analyze any test in a test suite, the book teaches
除了单元测试之外,本书还涵盖了自动化测试的整个主题,因此您还将了解集成和端到端测试。
In addition to unit tests, this book covers the entire topic of automated testing, so you’ll also learn about integration and end-to-end tests.
我的代码示例中使用了 C# 和 .NET,但您不必是 C# 专业人士即可阅读本书;C# 只是我最常使用的语言。我讨论的所有概念都不特定于语言,可以应用于任何其他面向对象的语言,例如 Java 或 C++。
I use C# and .NET in my code samples, but you don’t have to be a C# professional to read this book; C# is just the language that I happen to work with the most. All the concepts I talk about are non-language-specific and can be applied to any other object-oriented language, such as Java or C++.
本章涵盖
This chapter covers
正如第 1 章所述,单元测试的定义中存在大量细微差别。这些细微差别比您想象的更重要 — 以至于对它们的解释不同导致了对如何处理单元测试的两种截然不同的看法。
As mentioned in chapter 1, there are a surprising number of nuances in the definition of a unit test. Those nuances are more important than you might think—so much so that the differences in interpreting them have led to two distinct views on how to approach unit testing.
这些观点被称为单元测试的经典学派和伦敦学派。经典学派之所以被称为“经典”,是因为这是每个人最初接触单元测试和测试驱动开发的方式。伦敦学派扎根于伦敦的编程社区。本章中关于经典风格和伦敦风格之间差异的讨论为第 5 章奠定了基础,我将在该章中详细介绍模拟和测试脆弱性的主题。
These views are known as the classical and the London schools of unit testing. The classical school is called “classical” because it’s how everyone originally approached unit testing and test-driven development. The London school takes root in the programming community in London. The discussion in this chapter about the differences between the classical and London styles lays the foundation for chapter 5, where I cover the topic of mocks and test fragility in detail.
让我们首先定义单元测试,并说明所有注意事项和细节。这个定义是古典学派和伦敦学派之间的关键区别。
Let’s start by defining a unit test, with all due caveats and subtleties. This definition is the key to the difference between the classical and London schools.
单元测试有很多定义。除去非必要部分,这些定义都具有以下三个最重要的属性。单元测试是一种自动化测试,它
There are a lot of definitions of a unit test. Stripped of their non-essential bits, the definitions all have the following three most important attributes. A unit test is an automated test that
这里的头两个属性相当无争议。关于什么才是快速单元测试,可能会存在一些争议,因为这是一个非常主观的衡量标准。但总的来说,这并不重要。如果你的测试套件的执行时间对你来说足够好,那就意味着你的测试足够快。
The first two attributes here are pretty non-controversial. There might be some dispute as to what exactly constitutes a fast unit test because it’s a highly subjective measure. But overall, it’s not that important. If your test suite’s execution time is good enough for you, it means your tests are quick enough.
人们对第三个属性的看法大相径庭。隔离问题是传统和伦敦单元测试学派之间差异的根源。正如您将在下一节中看到的那样,这两个学派之间的所有其他差异都自然源于对隔离到底意味着什么的这一分歧。我更喜欢传统风格,原因我在第 2.3 节中进行了描述。
What people have vastly different opinions about is the third attribute. The isolation issue is the root of the differences between the classical and London schools of unit testing. As you will see in the next section, all other differences between the two schools flow naturally from this single disagreement on what exactly isolation means. I prefer the classical style for the reasons I describe in section 2.3.
经典方法也被称为底特律方法,有时也被称为单元测试的古典主义方法。关于古典学派的最经典书籍可能是 Kent Beck 所著的《测试驱动开发:示例》(Addison-Wesley Professional,2002 年)。
The classical approach is also referred to as the Detroit and, sometimes, the classicist approach to unit testing. Probably the most canonical book on the classical school is the one by Kent Beck: Test-Driven Development: By Example (Addison-Wesley Professional, 2002).
伦敦风格有时被称为mockist 。尽管mockist一词广为流传,但坚持这种单元测试风格的人通常不喜欢它,因此我在本书中将其称为伦敦风格。这种方法最著名的支持者是 Steve Freeman 和 Nat Pryce。我推荐他们的书《Growing Object-Oriented Software, Guided by Tests》(Addison-Wesley Professional,2009),这是关于这个主题的一个很好的资料。
The London style is sometimes referred to as mockist. Although the term mockist is widespread, people who adhere to this style of unit testing generally don’t like it, so I call it the London style throughout this book. The most prominent proponents of this approach are Steve Freeman and Nat Pryce. I recommend their book, Growing Object-Oriented Software, Guided by Tests (Addison-Wesley Professional, 2009), as a good source on this subject.
以隔离方式验证一段代码(一个单元)意味着什么?伦敦学派将其描述为将被测系统与其协作者隔离开来。这意味着,如果一个类依赖于另一个类或多个类,则需要用测试替身替换所有此类依赖项。这样,您就可以将其行为与任何外部影响分离开来,专注于被测类。
What does it mean to verify a piece of code—a unit—in an isolated manner? The London school describes it as isolating the system under test from its collaborators. It means if a class has a dependency on another class, or several classes, you need to replace all such dependencies with test doubles. This way, you can focus on the class under test exclusively by separating its behavior from any external influence.
测试替身是一种外观和行为与预期发布的对应物相似的对象,但实际上是简化版本,可降低复杂性并方便测试。Gerard Meszaros 在他的书《xUnit 测试模式:重构测试代码》(Addison-Wesley,2007 年)中引入了此术语。这个名字本身来自电影中的替身概念。
A test double is an object that looks and behaves like its release-intended counterpart but is actually a simplified version that reduces the complexity and facilitates testing. This term was introduced by Gerard Meszaros in his book, xUnit Test Patterns: Refactoring Test Code (Addison-Wesley, 2007). The name itself comes from the notion of a stunt double in movies.
图 2.1显示了隔离通常是如何实现的。单元测试原本会验证被测系统及其所有依赖项,而现在可以独立于这些依赖项进行验证。
Figure 2.1 shows how the isolation is usually achieved. A unit test that would otherwise verify the system under test along with all its dependencies now can do that separately from those dependencies.
这种方法的一个好处是,如果测试失败,您可以确定代码库的哪个部分出了问题:就是被测系统。不会有其他嫌疑人,因为该类的所有邻居都被替换为测试替身。
One benefit of this approach is that if the test fails, you know for sure which part of the code base is broken: it’s the system under test. There could be no other suspects, because all of the class’s neighbors are replaced with the test doubles.
另一个好处是可以拆分对象图(即解决同一问题的通信类的网络)。这个网络可能变得非常复杂:其中的每个类可能都有几个直接依赖项,每个依赖项都依赖于它们自己的依赖项,等等。类甚至可能引入循环依赖,其中依赖链最终回到它开始的地方。
Another benefit is the ability to split the object graph—the web of communicating classes solving the same problem. This web may become quite complicated: every class in it may have several immediate dependencies, each of which relies on dependencies of their own, and so on. Classes may even introduce circular dependencies, where the chain of dependency eventually comes back to where it started.
如果没有测试替身,测试这样一个相互关联的代码库是很困难的。你唯一能做的就是在测试中重新创建完整的对象图,但如果其中的类数太多,这可能就不是一项可行的任务。
Trying to test such an interconnected code base is hard without test doubles. Pretty much the only choice you are left with is re-creating the full object graph in the test, which might not be a feasible task if the number of classes in it is too high.
使用测试替身,您可以阻止这种情况。您可以替换类的直接依赖项;并且,通过扩展,您不必处理这些依赖项的依赖项,依此类推,沿着递归路径。您实际上是在分解图表 — 这可以大大减少您在单元测试中必须做的准备工作量。
With test doubles, you can put a stop to this. You can substitute the immediate dependencies of a class; and, by extension, you don’t have to deal with the dependencies of those dependencies, and so on down the recursion path. You are effectively breaking up the graph—and that can significantly reduce the amount of preparations you have to do in a unit test.
我们不要忘记这种单元测试隔离方法的另一个小而令人愉快的附带好处:它允许您引入一个项目范围的指导方针,即一次只测试一个类,从而在整个单元测试套件中建立一个简单的结构。您不再需要考虑如何用测试覆盖您的代码库。有一个类?创建一个带有单元测试的相应类!图 2.2显示了它通常的样子。
And let’s not forget another small but pleasant side benefit of this approach to unit test isolation: it allows you to introduce a project-wide guideline of testing only one class at a time, which establishes a simple structure in the whole unit test suite. You no longer have to think much about how to cover your code base with tests. Have a class? Create a corresponding class with unit tests! Figure 2.2 shows how it usually looks.
现在让我们看一些例子。由于古典风格可能对大多数人来说更熟悉,我将首先展示以这种风格编写的样本测试,然后使用伦敦方法重写它们。
Let’s now look at some examples. Since the classical style probably looks more familiar to most people, I’ll show sample tests written in that style first and then rewrite them using the London approach.
假设我们经营一家网上商店。我们的示例应用程序中只有一个简单的用例:客户可以购买产品。当商店中有足够的库存时,购买被视为成功,商店中的产品数量将减少购买金额。如果产品不足,则购买不成功,商店中不会发生任何事情。
Let’s say that we operate an online store. There’s just one simple use case in our sample application: a customer can purchase a product. When there’s enough inventory in the store, the purchase is deemed to be successful, and the amount of the product in the store is reduced by the purchase’s amount. If there’s not enough product, the purchase is not successful, and nothing happens in the store.
清单 2.1展示了两个测试,用于验证只有当商店中有足够的库存时购买才会成功。测试以经典风格编写,并使用典型的三阶段序列:安排、行动和断言(简称 AAA——我在第 3 章中详细讨论了这一序列)。
Listing 2.1 shows two tests verifying that a purchase succeeds only when there’s enough inventory in the store. The tests are written in the classical style and use the typical three-phase sequence: arrange, act, and assert (AAA for short—I talk more about this sequence in chapter 3).
[事实]
public void Purchase_succeeds_when_enough_inventory()
{
// 安排
var store = new Store();
商店.添加库存(产品.洗发水,10);
var 客户 = 新客户();
// 行为
bool 成功 = 顾客.购买(商店, 产品.洗发水, 5);
// 断言
断言.True(成功);
断言.等于(5,store.GetInventory(Product.Shampoo)); 1
}
[事实]
public void Purchase_fails_when_not_enough_inventory()
{
// 安排
var store = new Store();
商店.添加库存(产品.洗发水,10);
var 客户 = 新客户();
// 行为
bool success = customer.Purchase(商店,产品。洗发水,15);
// 断言
断言.False(成功);
断言.等于(10,store.GetInventory(Product.Shampoo)); 2
}
公共枚举产品
{
洗发水,
书
}[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
// Arrange
var store = new Store();
store.AddInventory(Product.Shampoo, 10);
var customer = new Customer();
// Act
bool success = customer.Purchase(store, Product.Shampoo, 5);
// Assert
Assert.True(success);
Assert.Equal(5, store.GetInventory(Product.Shampoo)); 1
}
[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
// Arrange
var store = new Store();
store.AddInventory(Product.Shampoo, 10);
var customer = new Customer();
// Act
bool success = customer.Purchase(store, Product.Shampoo, 15);
// Assert
Assert.False(success);
Assert.Equal(10, store.GetInventory(Product.Shampoo)); 2
}
public enum Product
{
Shampoo,
Book
}
如您所见,安排部分是测试准备好所有依赖项和被测系统的地方。调用customer.Purchase()是行为阶段,在此阶段,您可以执行要验证的行为。断言语句是验证阶段,在此阶段,您可以检查行为是否导致预期结果。
As you can see, the arrange part is where the tests make ready all dependencies and the system under test. The call to customer.Purchase() is the act phase, where you exercise the behavior you want to verify. The assert statements are the verification stage, where you check to see if the behavior led to the expected results.
在安排阶段,测试将两种对象组合在一起:被测系统 (SUT) 和一个协作者。在本例中,Customer是 SUT,Store是协作者。我们需要协作者有两个原因:
During the arrange phase, the tests put together two kinds of objects: the system under test (SUT) and one collaborator. In this case, Customer is the SUT and Store is the collaborator. We need the collaborator for two reasons:
Product.Shampoo并且数字5和15是常数。
Product.Shampoo and the numbers 5 and 15 are constants.
被测方法( MUT )是测试调用的 SUT 中的方法。术语MUT和SUT经常用作同义词,但通常情况下,MUT指的是方法,而SUT指的是整个类。
A method under test (MUT) is a method in the SUT called by the test. The terms MUT and SUT are often used as synonyms, but normally, MUT refers to a method while SUT refers to the whole class.
此代码是经典单元测试风格的一个示例:测试不会替换协作者(Store类),而是使用它的可投入生产的实例。这种风格的自然结果之一是,测试现在有效地验证了和Customer,Store而不仅仅是Customer。内部工作中的任何错误Store都会Customer影响到这些单元测试,即使Customer仍然正常工作。这两个类在测试中并不相互隔离。
This code is an example of the classical style of unit testing: the test doesn’t replace the collaborator (the Store class) but rather uses a production-ready instance of it. One of the natural outcomes of this style is that the test now effectively verifies both Customer and Store, not just Customer. Any bug in the inner workings of Store that affects Customer will lead to failing these unit tests, even if Customer still works correctly. The two classes are not isolated from each other in the tests.
现在让我们将示例修改为伦敦风格。我将进行相同的测试,并将Store实例替换为测试替身——具体来说,是模拟。
Let’s now modify the example toward the London style. I’ll take the same tests and replace the Store instances with test doubles—specifically, mocks.
我使用 Moq ( https://github.com/moq/moq4 ) 作为模拟框架,但您可以找到几个同样优秀的替代方案,例如 NSubstitute ( https://github.com/nsubstitute/NSubstitute )。所有面向对象语言都有类似的框架。例如,在 Java 世界中,您可以使用 Mockito、JMock 或 EasyMock。
I use Moq (https://github.com/moq/moq4) as the mocking framework, but you can find several equally good alternatives, such as NSubstitute (https://github.com/nsubstitute/NSubstitute). All object-oriented languages have analogous frameworks. For instance, in the Java world, you can use Mockito, JMock, or EasyMock.
模拟是一种特殊的测试替身,可以让你检查被测系统与其协作者之间的交互。
A mock is a special kind of test double that allows you to examine interactions between the system under test and its collaborators.
在后面的章节中,我们会重新讨论模拟、存根以及它们之间的区别。现在,主要要记住的是模拟是测试替身的子集。人们经常将测试替身和模拟这两个术语作为同义词使用,但从技术上讲,它们并不是同义词(第 5 章将对此进行详细介绍):
We’ll get back to the topic of mocks, stubs, and the differences between them in later chapters. For now, the main thing to remember is that mocks are a subset of test doubles. People often use the terms test double and mock as synonyms, but technically, they are not (more on this in chapter 5):
Customer下一个清单显示了与其合作者隔离之后测试的样子Store。
The next listing shows how the tests look after isolating Customer from its collaborator, Store.
[事实]
public void Purchase_succeeds_when_enough_inventory()
{
// 安排
var storeMock = new Mock<IStore>();
存储模拟
.设置(x => x.HasEnoughInventory(产品.洗发水,5))
.返回(true);
var 客户 = 新客户();
// 行为
bool 成功 = 客户.购买(
storeMock.Object,产品.洗发水,5);
// 断言
断言.True(成功);
storeMock.验证(
x => x.删除库存(产品.洗发水,5),
次.一次);
}
[事实]
public void Purchase_fails_when_not_enough_inventory()
{
// 安排
var storeMock = new Mock<IStore>();
存储模拟
.设置(x => x.HasEnoughInventory(产品.洗发水,5))
.返回(false);
var 客户 = 新客户();
// 行为
bool 成功 = 客户.购买(
storeMock.Object,产品.洗发水,5);
// 断言
断言.False(成功);
storeMock.验证(
x => x.删除库存(产品.洗发水,5),
次.从不);
}[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
// Arrange
var storeMock = new Mock<IStore>();
storeMock
.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
.Returns(true);
var customer = new Customer();
// Act
bool success = customer.Purchase(
storeMock.Object, Product.Shampoo, 5);
// Assert
Assert.True(success);
storeMock.Verify(
x => x.RemoveInventory(Product.Shampoo, 5),
Times.Once);
}
[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
// Arrange
var storeMock = new Mock<IStore>();
storeMock
.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
.Returns(false);
var customer = new Customer();
// Act
bool success = customer.Purchase(
storeMock.Object, Product.Shampoo, 5);
// Assert
Assert.False(success);
storeMock.Verify(
x => x.RemoveInventory(Product.Shampoo, 5),
Times.Never);
}
请注意这些测试与以传统风格编写的测试有何不同。在安排阶段,测试不再实例化可用于生产的实例,Store而是使用 Moq 的内置类为其创建替代品Mock<T>。
Note how different these tests are from those written in the classical style. In the arrange phase, the tests no longer instantiate a production-ready instance of Store but instead create a substitution for it, using Moq’s built-in class Mock<T>.
此外,我们无需通过添加洗发水库存来修改的状态Store,而是直接告诉模拟如何响应对的调用HasEnoughInventory()。无论的实际状态如何,模拟都会按照测试需要的方式对此请求做出反应Store。实际上,测试不再使用Store——我们引入了一个IStore接口,并且模拟的是该接口而不是Store类。
Furthermore, instead of modifying the state of Store by adding a shampoo inventory to it, we directly tell the mock how to respond to calls to HasEnoughInventory(). The mock reacts to this request the way the tests need, regardless of the actual state of Store. In fact, the tests no longer use Store—we have introduced an IStore interface and are mocking that interface instead of the Store class.
在第 8 章中,我详细介绍了如何使用接口。现在,只需记下接口是将测试系统与其协作者隔离所必需的。(您也可以模拟一个具体类,但这是一种反模式;我在第 11 章中介绍了这个主题。)
In chapter 8, I write in detail about working with interfaces. For now, just make a note that interfaces are required for isolating the system under test from its collaborators. (You can also mock a concrete class, but that’s an anti-pattern; I cover this topic in chapter 11.)
断言阶段也发生了变化,这就是关键的区别所在。我们仍然像以前一样检查输出customer.Purchase,但我们验证客户对商店所做的操作是否正确的方式有所不同。以前,我们通过断言商店的状态来做到这一点。现在,我们检查Customer和之间的交互Store:测试检查客户是否对商店进行了正确的调用。我们通过传递客户应该在商店上调用的方法(x.RemoveInventory)以及应该调用的次数来做到这一点。如果购买成功,客户应该调用此方法一次(Times.Once)。如果购买失败,客户根本不应该调用它(Times.Never)。
The assertion phase has changed too, and that’s where the key difference lies. We still check the output from customer.Purchase as before, but the way we verify that the customer did the right thing to the store is different. Previously, we did that by asserting against the store’s state. Now, we examine the interactions between Customer and Store: the tests check to see if the customer made the correct call on the store. We do this by passing the method the customer should call on the store (x.RemoveInventory) as well as the number of times it should do that. If the purchases succeeds, the customer should call this method once (Times.Once). If the purchases fails, the customer shouldn’t call it at all (Times.Never).
重申一下,伦敦风格通过测试替身(具体来说就是模拟)将被测试的代码片段与其协作者隔离开来,从而满足了隔离要求。有趣的是,这种观点还会影响您对一小段代码(单元)的构成的看法。以下再次列出了单元测试的所有属性:
To reiterate, the London style approaches the isolation requirement by segregating the piece of code under test from its collaborators with the help of test doubles: specifically, mocks. Interestingly enough, this point of view also affects your standpoint on what constitutes a small piece of code (a unit). Here are all the attributes of a unit test once again:
除了第三个属性留有解释空间之外,第一个属性的可能解释也有一定的空间。一小段代码应该有多小?正如您在上一节中看到的,如果您采取隔离每个单独类的立场,那么很自然地会接受被测试的代码段也应该是单个类,或者是该类内的一个方法。由于您处理隔离问题的方式,它不能超过这个范围。在某些情况下,您可能会同时测试几个类;但一般来说,您总是会努力保持每次只测试一个类的单元测试这一准则。
In addition to the third attribute leaving room for interpretation, there’s some room in the possible interpretations of the first attribute as well. How small should a small piece of code be? As you saw from the previous section, if you adopt the position of isolating every individual class, then it’s natural to accept that the piece of code under test should also be a single class, or a method inside that class. It can’t be more than that due to the way you approach the isolation issue. In some cases, you might test a couple of classes at once; but in general, you’ll always strive to maintain this guideline of unit testing one class at a time.
正如我之前提到的,还有另一种解释隔离属性的方法——传统方法。在传统方法中,不需要以隔离的方式测试代码。相反,单元测试本身应该彼此隔离地运行。这样,您可以并行、按顺序和以任何最适合您的顺序运行测试,而且它们仍然不会影响彼此的结果。
As I mentioned earlier, there’s another way to interpret the isolation attribute—the classical way. In the classical approach, it’s not the code that needs to be tested in an isolated manner. Instead, unit tests themselves should be run in isolation from each other. That way, you can run the tests in parallel, sequentially, and in any order, whatever fits you best, and they still won’t affect each other’s outcome.
测试彼此隔离意味着可以同时执行多个类,只要它们都驻留在内存中并且不会达到共享状态,测试可以通过该共享状态进行通信并影响彼此的执行上下文。这种共享状态的典型示例是进程外依赖项 - 数据库、文件系统等。
Isolating tests from each other means it’s fine to exercise several classes at once as long as they all reside in the memory and don’t reach out to a shared state, through which the tests can communicate and affect each other’s execution context. Typical examples of such a shared state are out-of-process dependencies—the database, the file system, and so on.
例如,一个测试可以在其安排阶段在数据库中创建一个客户,而另一个测试会在第一个测试完成执行之前,在其自己的安排阶段将其删除。如果并行运行这两个测试,第一个测试将失败,不是因为生产代码有问题,而是因为第二个测试的干扰。
For instance, one test could create a customer in the database as part of its arrange phase, and another test would delete it as part of its own arrange phase, before the first test completes executing. If you run these two tests in parallel, the first test will fail, not because the production code is broken, but rather because of the interference from the second test.
共享依赖关系是在测试之间共享的依赖关系,它为这些测试提供了影响彼此结果的方法。共享依赖关系的一个典型示例是静态可变字段。对此类字段的更改对同一进程内运行的所有单元测试都可见。数据库是共享依赖关系的另一个典型示例。
A shared dependency is a dependency that is shared between tests and provides means for those tests to affect each other’s outcome. A typical example of shared dependencies is a static mutable field. A change to such a field is visible across all unit tests running within the same process. A database is another typical example of a shared dependency.
私有依赖项是不共享的依赖项。
A private dependency is a dependency that is not shared.
进程外依赖项是在应用程序执行进程之外运行的依赖项;它是尚未进入内存的数据的代理。进程外依赖项在绝大多数情况下对应于共享依赖项,但并非总是如此。例如,数据库既是进程外的又是共享的。但如果在每次测试运行之前在 Docker 容器中启动该数据库,则会使此依赖项成为进程外的但不共享的,因为测试不再使用它的同一实例。同样,只读数据库也是进程外的但不共享的,即使它被测试重用。测试无法改变此类数据库中的数据,因此不会影响彼此的结果。
An out-of-process dependency is a dependency that runs outside the application’s execution process; it’s a proxy to data that is not yet in the memory. An out-of-process dependency corresponds to a shared dependency in the vast majority of cases, but not always. For example, a database is both out-of-process and shared. But if you launch that database in a Docker container before each test run, that would make this dependency out-of-process but not shared, since tests no longer work with the same instance of it. Similarly, a read-only database is also out-of-process but not shared, even if it’s reused by tests. Tests can’t mutate data in such a database and thus can’t affect each other’s outcome.
这种对隔离问题的看法需要对使用模拟和其他测试替身采取更为温和的看法。您仍然可以使用它们,但通常只对那些在测试之间引入共享状态的依赖项这样做。图 2.3显示了它的样子。
This take on the isolation issue entails a much more modest view on the use of mocks and other test doubles. You can still use them, but you normally do that for only those dependencies that introduce a shared state between tests. Figure 2.3 shows how it looks.
请注意,共享依赖项在单元测试之间共享,而不是在测试类(单元)之间共享。从这个意义上讲,只要您能够在每个测试中创建它的新实例,单例依赖项就不会被共享。虽然只有一个实例在生产代码中,如果使用单例,测试很可能不会遵循这种模式,也不会重用该单例。因此,这种依赖关系将是私有的。
Note that shared dependencies are shared between unit tests, not between classes under test (units). In that sense, a singleton dependency is not shared as long as you are able to create a new instance of it in each test. While there’s only one instance of a singleton in the production code, tests may very well not follow this pattern and not reuse that singleton. Thus, such a dependency would be private.
例如,配置类通常只有一个实例,该实例在所有生产代码中重复使用。但是,如果它像所有其他依赖项一样通过构造函数注入到 SUT 中,则可以在每个测试中创建它的新实例;您不必在整个测试套件中维护单个实例。但是,您不能创建新的文件系统或数据库;它们必须在测试之间共享或用测试替身代替。
For example, there’s normally only one instance of a configuration class, which is reused across all production code. But if it’s injected into the SUT the way all other dependencies are, say, via a constructor, you can create a new instance of it in each test; you don’t have to maintain a single instance throughout the test suite. You can’t create a new file system or a database, however; they must be either shared between tests or substituted away with test doubles.
另一个术语具有相似但不完全相同的含义:易变依赖关系。我推荐Steven van Deursen 和 Mark Seemann 合著的《依赖注入:原则、实践、模式》(Manning Publications,2018 年)作为依赖管理主题的必读书籍。
Another term has a similar, yet not identical, meaning: volatile dependency. I recommend Dependency Injection: Principles, Practices, Patterns by Steven van Deursen and Mark Seemann (Manning Publications, 2018) as a go-to book on the topic of dependency management.
易失性依赖项是具有下列属性之一的依赖项:
A volatile dependency is a dependency that exhibits one of the following properties:
如您所见,共享依赖项和易失依赖项的概念之间存在重叠。例如,对数据库的依赖项既是共享的又是易失的。但文件系统并非如此。文件系统不是易失性的,因为它安装在每个开发人员的机器上,并且在大多数情况下其行为都是确定性的。尽管如此,文件系统引入了一种单元测试可以干扰彼此执行上下文的方法;因此它是共享的。同样,随机数生成器是易失性的,但由于您可以为每个测试提供它的单独实例,因此它不是共享的。
As you can see, there’s an overlap between the notions of shared and volatile dependencies. For example, a dependency on the database is both shared and volatile. But that’s not the case for the file system. The file system is not volatile because it is installed on every developer’s machine and it behaves deterministically in the vast majority of cases. Still, the file system introduces a means by which the unit tests can interfere with each other’s execution context; hence it is shared. Likewise, a random number generator is volatile, but because you can supply a separate instance of it to each test, it isn’t shared.
替换共享依赖项的另一个原因是提高测试执行速度。共享依赖项几乎总是位于执行过程之外,而私有依赖项通常不会跨越该边界。因此,对共享依赖项(例如数据库或文件系统)的调用比对私有依赖项的调用花费更多时间。而且由于快速运行的必要性是单元测试定义的第二个属性,因此此类调用将具有共享依赖项的测试推出了单元测试的范围并进入了集成测试领域。我将在本章后面详细介绍集成测试。
Another reason for substituting shared dependencies is to increase the test execution speed. Shared dependencies almost always reside outside the execution process, while private dependencies usually don’t cross that boundary. Because of that, calls to shared dependencies, such as a database or the file system, take more time than calls to private dependencies. And since the necessity to run quickly is the second attribute of the unit test definition, such calls push the tests with shared dependencies out of the realm of unit testing and into the area of integration testing. I talk more about integration testing later in this chapter.
这种对隔离的另类看法也导致了对单元(一小段代码)构成的不同看法。单元不一定非要局限于一个类。您也可以对一组类进行单元测试,只要它们都不是共享依赖项。
This alternative view of isolation also leads to a different take on what constitutes a unit (a small piece of code). A unit doesn’t necessarily have to be limited to a class. You can just as well unit test a group of classes, as long as none of them is a shared dependency.
如你所见,伦敦学派和古典学派之间的差异的根源在于隔离属性。伦敦学派认为隔离是被测系统与其合作者的隔离,而古典学派认为隔离是单元测试本身之间的隔离。
As you can see, the root of the differences between the London and classical schools is the isolation attribute. The London school views it as isolation of the system under test from its collaborators, whereas the classical school views it as isolation of unit tests themselves from each other.
这种看似微小的差异导致了关于如何处理单元测试的巨大分歧,正如您所知,这产生了两种思想流派。总的来说,两派之间的分歧涉及三个主要主题:
This seemingly minor difference has led to a vast disagreement about how to approach unit testing, which, as you already know, produced the two schools of thought. Overall, the disagreement between the schools spans three major topics:
表 2.1对此进行了总结。
Table 2.1 sums it all up.
|
隔离 Isolation of |
一个单位是 A unit is |
使用测试替身 Uses test doubles for |
|
|---|---|---|---|
| 伦敦学派 | 单位 | 一个类 | 除了不可变的依赖关系之外的所有依赖关系 |
| 古典学派 | 单元测试 | 一个类或一组类 | 共享依赖项 |
请注意,尽管测试替身随处可见,但伦敦学派仍然允许在测试中按原样使用某些依赖项。这里的试金石是依赖项是否可变。不要用永远不会改变的对象(不可变对象)来替换它们。
Note that despite the ubiquitous use of test doubles, the London school still allows for using some dependencies in tests as-is. The litmus test here is whether a dependency is mutable. It’s fine not to substitute objects that don’t ever change—immutable objects.
并且您在前面的例子中看到,当我将测试重构为伦敦风格时,我没有Product用模拟替换实例,而是使用了真实对象,如下面的代码所示(为方便起见,从清单 2.2重复):
And you saw in the earlier examples that, when I refactored the tests toward the London style, I didn’t replace the Product instances with mocks but rather used the real objects, as shown in the following code (repeated from listing 2.2 for your convenience):
[事实]
public void Purchase_fails_when_not_enough_inventory()
{
// 安排
var storeMock = new Mock<IStore>();
存储模拟
.设置(x => x.HasEnoughInventory(产品.洗发水,5))
.返回(false);
var 客户 = 新客户();
// 行为
bool 成功 = 客户.购买(storeMock.Object, Product.Shampoo, 5);
// 断言
断言.False(成功);
storeMock.验证(
x => x.删除库存(产品.洗发水,5),
次.从不);
}[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
// Arrange
var storeMock = new Mock<IStore>();
storeMock
.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
.Returns(false);
var customer = new Customer();
// Act
bool success = customer.Purchase(storeMock.Object, Product.Shampoo, 5);
// Assert
Assert.False(success);
storeMock.Verify(
x => x.RemoveInventory(Product.Shampoo, 5),
Times.Never);
}
在 的两个依赖项中Customer,仅Store包含可随时间变化的内部状态。Product实例是不可变的(Product本身是 C# 枚举)。因此,我Store仅替换了实例。
Of the two dependencies of Customer, only Store contains an internal state that can change over time. The Product instances are immutable (Product itself is a C# enum). Hence I substituted the Store instance only.
如果你仔细想想,就会明白这是有道理的。你5也不会在上一个测试中使用数字的测试替身,对吧?那是因为它也是不可变的——你不可能修改这个数字。请注意,我说的不是包含数字的变量,而是数字本身。在语句中RemoveInventory(Product.Shampoo, 5),我们甚至没有使用变量;5立即声明。同样如此Product.Shampoo。
It makes sense, if you think about it. You wouldn’t use a test double for the 5 number in the previous test either, would you? That’s because it is also immutable—you can’t possibly modify this number. Note that I’m not talking about a variable containing the number, but rather the number itself. In the statement RemoveInventory(Product.Shampoo, 5), we don’t even use a variable; 5 is declared right away. The same is true for Product.Shampoo.
这种不可变对象称为值对象或值。它们的主要特征是没有单独的身份;它们仅通过其内容进行标识。作为推论,如果两个这样的对象具有相同的内容,则使用哪个并不重要:这些实例是可以互换的。例如,如果您有两个5整数,则可以互相替换使用它们。对于我们案例中的产品也是如此:您可以重用单个Product.Shampoo实例或声明其中的多个实例 - 没有任何区别。这些实例将具有相同的内容,因此可以互换使用。
Such immutable objects are called value objects or values. Their main trait is that they have no individual identity; they are identified solely by their content. As a corollary, if two such objects have the same content, it doesn’t matter which of them you’re working with: these instances are interchangeable. For example, if you’ve got two 5 integers, you can use them in place of one another. The same is true for the products in our case: you can reuse a single Product.Shampoo instance or declare several of them—it won’t make any difference. These instances will have the same content and thus can be used interchangeably.
请注意,值对象的概念与语言无关,不需要特定的编程语言或框架。您可以在我的文章“实体与值对象:最终差异列表”中阅读有关值对象的更多信息,网址为http://mng.bz/KE9O。
Note that the concept of a value object is language-agnostic and doesn’t require a particular programming language or framework. You can read more about value objects in my article “Entity vs. Value Object: The ultimate list of differences” at http://mng.bz/KE9O.
图 2.4显示了依赖项的分类以及两种单元测试流派如何处理它们。依赖项可以是共享的,也可以是私有的。而私有依赖项则可以是可变的,也可以是不可变的。在后一种情况下,它被称为值对象。例如,数据库是一个共享依赖项——它的内部状态在所有自动化测试(不会用测试替身替换它)之间共享。Store实例是可变的私有依赖项。实例Product(或者数字的实例5)是一个不可变的私有依赖项的例子——一个值对象。所有共享依赖项都是可变的,但要使可变依赖项实现共享,它必须被测试重用。
Figure 2.4 shows the categorization of dependencies and how both schools of unit testing treat them. A dependency can be either shared or private. A private dependency, in turn, can be either mutable or immutable. In the latter case, it is called a value object. For example, a database is a shared dependency—its internal state is shared across all automated tests (that don’t replace it with a test double). A Store instance is a private dependency that is mutable. And a Product instance (or an instance of a number 5, for that matter) is an example of a private dependency that is immutable—a value object. All shared dependencies are mutable, but for a mutable dependency to be shared, it has to be reused by tests.
为了您的方便,我重复了表 2.1,其中列出了各学校之间的差异。
I’m repeating table 2.1 with the differences between the schools for your convenience.
|
隔离 Isolation of |
一个单位是 A unit is |
使用测试替身 Uses test doubles for |
|
|---|---|---|---|
| 伦敦学派 | 单位 | 一个类 | 除了不可变的依赖关系之外的所有依赖关系 |
| 古典学派 | 单元测试 | 一个类或一组类 | 共享依赖项 |
协作者是一种共享或可变的依赖项。例如,提供数据库访问权限的类是协作者,因为数据库是共享依赖项。也是一个协作者,因为它的状态可以随时间而变化。Store
A collaborator is a dependency that is either shared or mutable. For example, a class providing access to the database is a collaborator since the database is a shared dependency. Store is a collaborator too, because its state can change over time.
Product和 number5也是依赖项,但它们不是协作者。它们是值或值对象。
Product and number 5 are also dependencies, but they’re not collaborators. They’re values or value objects.
一个典型的类可能会同时处理两种类型的依赖项:协作者和值。看一下这个方法调用:
A typical class may work with dependencies of both types: collaborators and values. Look at this method call:
顾客.购买(商店,产品.洗发水,5)
customer.Purchase(store, Product.Shampoo, 5)
这里我们有三个依赖项。其中一个(store)是合作者,另外两个(Product.Shampoo,5)不是。
Here we have three dependencies. One of them (store) is a collaborator, and the other two (Product.Shampoo, 5) are not.
让我重申一下有关依赖类型的一点。并非所有进程外依赖都属于共享依赖类别。共享依赖几乎总是位于应用程序进程之外,但反之则不然(见图2.5)。为了共享进程外依赖,它必须提供单元测试相互通信的手段。通信是通过修改依赖的内部状态来完成的。从这个意义上讲,不可变的进程外依赖不提供这样的手段。测试根本无法修改其中的任何内容,因此无法干扰彼此的执行上下文。
And let me reiterate one point about the types of dependencies. Not all out-of-process dependencies fall into the category of shared dependencies. A shared dependency almost always resides outside the application’s process, but the opposite isn’t true (see figure 2.5). In order for an out-of-process dependency to be shared, it has to provide means for unit tests to communicate with each other. The communication is done through modifications of the dependency’s internal state. In that sense, an immutable out-of-process dependency doesn’t provide such a means. The tests simply can’t modify anything in it and thus can’t interfere with each other’s execution context.
例如,如果某个地方有一个 API 返回组织销售的所有产品的目录,只要该 API 不公开更改目录的功能,这就不是共享依赖项。确实,这种依赖项是不稳定的并且位于应用程序边界之外,但由于测试无法影响它返回的数据,因此它不是共享的。这并不意味着您必须在测试范围内包含这样的依赖项。在大多数情况下,您仍然需要用测试替身替换它以保持测试速度。但如果进程外依赖项足够快并且与它的连接稳定,那么您可以在测试中按原样使用它。
For example, if there’s an API somewhere that returns a catalog of all products the organization sells, this isn’t a shared dependency as long as the API doesn’t expose the functionality to change the catalog. It’s true that such a dependency is volatile and sits outside the application’s boundary, but since the tests can’t affect the data it returns, it isn’t shared. This doesn’t mean you have to include such a dependency in the testing scope. In most cases, you still need to replace it with a test double to keep the test fast. But if the out-of-process dependency is quick enough and the connection to it is stable, you can make a good case for using it as-is in the tests.
话虽如此,在本书中,除非我明确说明,否则我会交替使用共享依赖项和进程外依赖项这两个术语。在实际项目中,您很少会遇到非进程外的共享依赖项。如果依赖项是进程内的,您可以轻松地为每个测试提供单独的实例;无需在测试之间共享它。同样,您通常不会遇到进程外不共享的依赖项。大多数此类依赖项都是可变的,因此可以通过测试进行修改。
Having that said, in this book, I use the terms shared dependency and out-of-process dependency interchangeably unless I explicitly state otherwise. In real-world projects, you rarely have a shared dependency that isn’t out-of-process. If a dependency is in-process, you can easily supply a separate instance of it to each test; there’s no need to share it between tests. Similarly, you normally don’t encounter an out-of-process dependency that’s not shared. Most such dependencies are mutable and thus can be modified by tests.
基于这样的定义,我们来对比一下这两个学派的优点。
With this foundation of definitions, let’s contrast the two schools on their merits.
重申一下,古典学派和伦敦学派的主要区别在于他们如何处理单元测试定义中的隔离问题。这反过来又延伸到对单元(应该接受测试的东西)的处理以及处理依赖关系的方法。
To reiterate, the main difference between the classical and London schools is in how they treat the isolation issue in the definition of a unit test. This, in turn, spills over to the treatment of a unit—the thing that should be put under test—and the approach to handling dependencies.
正如我之前提到的,我更喜欢经典的单元测试流派。它倾向于生成更高质量的测试,因此更适合实现单元测试的最终目标,即项目的可持续增长。原因是脆弱性:使用模拟的测试往往比传统测试更脆弱(更多内容请参见第 5 章)。现在,让我们逐一评估伦敦学派的主要卖点。
As I mentioned previously, I prefer the classical school of unit testing. It tends to produce tests of higher quality and thus is better suited for achieving the ultimate goal of unit testing, which is the sustainable growth of your project. The reason is fragility: tests that use mocks tend to be more brittle than classical tests (more on this in chapter 5). For now, let’s take the main selling points of the London school and evaluate them one by one.
伦敦学校的做法有以下好处:
The London school’s approach provides the following benefits:
关于更好粒度的观点与单元测试中单元构成的讨论有关。伦敦学派将类视为这样的单元。开发人员来自面向对象编程背景,他们通常将类视为每个代码库基础的原子构建块。这自然导致也将类视为测试中要验证的原子单元。这种倾向可以理解,但具有误导性。
The point about better granularity relates to the discussion about what constitutes a unit in unit testing. The London school considers a class as such a unit. Coming from an object-oriented programming background, developers usually regard classes as the atomic building blocks that lie at the foundation of every code base. This naturally leads to treating classes as the atomic units to be verified in tests, too. This tendency is understandable but misleading.
测试不应该验证代码单元。相反,它们应该验证行为单元:对问题领域有意义的东西,理想情况下,是业务人员可以识别为有用的东西。实现这种行为单元所需的类数无关紧要。该单元可以跨越多个类或仅一个类,甚至只占用一个很小的方法。
Tests shouldn’t verify units of code. Rather, they should verify units of behavior: something that is meaningful for the problem domain and, ideally, something that a business person can recognize as useful. The number of classes it takes to implement such a unit of behavior is irrelevant. The unit could span across multiple classes or only one class, or even take up just a tiny method.
因此,以更好的代码粒度为目标并没有帮助。只要测试检查了单个行为单元,它就是一个好的测试。如果目标低于这个目标,实际上会损害你的单元测试,因为更难理解这些测试究竟验证了什么。测试应该讲述你的代码有助于解决的问题,这个故事应该具有凝聚力,对非程序员来说有意义。
And so, aiming at better code granularity isn’t helpful. As long as the test checks a single unit of behavior, it’s a good test. Targeting something less than that can in fact damage your unit tests, as it becomes harder to understand exactly what these tests verify. A test should tell a story about the problem your code helps to solve, and this story should be cohesive and meaningful to a non-programmer.
例如,这是一个具有凝聚力的故事的例子:
For instance, this is an example of a cohesive story:
当我呼唤我的狗时,它就会直接来到我身边。
When I call my dog, he comes right to me.
现在将其与以下内容进行比较:
Now compare it to the following:
当我呼唤我的狗时,它先移动左前腿,然后移动右前腿 腿,头转动,尾巴开始摇摆......
When I call my dog, he moves his front left leg first, then the front right leg, his head turns, the tail start wagging...
第二个故事就没那么合理了。所有这些动作的目的是什么?狗是来找我吗?还是在逃跑?你分不清。当你针对单个类(狗的腿、头和尾巴)而不是实际行为(狗来到主人身边)时,你的测试就会开始变成这样。我将在第 5 章中更多地讨论可观察行为这一主题以及如何将其与内部实现细节区分开来。
The second story makes much less sense. What’s the purpose of all those movements? Is the dog coming to me? Or is he running away? You can’t tell. This is what your tests start to look like when you target individual classes (the dog’s legs, head, and tail) instead of the actual behavior (the dog coming to his master). I talk more about this topic of observable behavior and how to differentiate it from internal implementation details in chapter 5.
使用模拟对象代替真实协作者可以更轻松地测试类,尤其是在存在复杂的依赖关系图的情况下,被测类具有依赖关系,每个依赖关系都依赖于其自身的依赖关系,依此类推,深度达几层。使用测试替身,您可以替换类的直接依赖关系,从而分解图,这可以大大减少单元测试中需要做的准备工作量。如果您遵循传统学派,则必须重新创建完整的对象图(共享依赖关系除外),只是为了设置被测系统,这可能需要大量工作。
The use of mocks in place of real collaborators can make it easier to test a class—especially when there’s a complicated dependency graph, where the class under test has dependencies, each of which relies on dependencies of its own, and so on, several layers deep. With test doubles, you can substitute the class’s immediate dependencies and thus break up the graph, which can significantly reduce the amount of preparation you have to do in a unit test. If you follow the classical school, you have to re-create the full object graph (with the exception of shared dependencies) just for the sake of setting up the system under test, which can be a lot of work.
虽然这都是真的,但这种推理关注的是错误的问题。你不应该想办法测试一个庞大而复杂的相互关联的类图,而应该首先关注没有这样的类图。通常情况下,大型类图是代码设计问题的结果。
Although this is all true, this line of reasoning focuses on the wrong problem. Instead of finding ways to test a large, complicated graph of interconnected classes, you should focus on not having such a graph of classes in the first place. More often than not, a large class graph is a result of a code design problem.
测试指出了这个问题其实是件好事。正如我们在第 1 章中讨论的那样,对一段代码进行单元测试的能力是一个很好的负面指标——它以相对较高的精度预测代码质量较差。如果您发现要对一个类进行单元测试,您需要将测试的安排阶段延长到超出所有合理限制的范围,那么这肯定是麻烦的征兆。使用模拟只会隐藏这个问题;它并没有解决根本原因。我在第 2 部分中讨论了如何修复底层代码设计问题。
It’s actually a good thing that the tests point out this problem. As we discussed in chapter 1, the ability to unit test a piece of code is a good negative indicator—it predicts poor code quality with a relatively high precision. If you see that to unit test a class, you need to extend the test’s arrange phase beyond all reasonable limits, it’s a certain sign of trouble. The use of mocks only hides this problem; it doesn’t tackle the root cause. I talk about how to fix the underlying code design problem in part 2.
如果您将错误引入使用伦敦式测试的系统,通常只会导致 SUT 包含错误的测试失败。但是,使用传统方法,针对故障类的客户端的测试也可能失败。这会导致连锁反应,单个错误可能会导致整个系统的测试失败。因此,找到问题的根源变得更加困难。您可能需要花一些时间调试测试才能找出原因。
If you introduce a bug to a system with London-style tests, it normally causes only tests whose SUT contains the bug to fail. However, with the classical approach, tests that target the clients of the malfunctioning class can also fail. This leads to a ripple effect where a single bug can cause test failures across the whole system. As a result, it becomes harder to find the root of the issue. You might need to spend some time debugging the tests to figure it out.
这是一个合理的担忧,但我认为这不是一个大问题。如果你定期运行测试(理想情况下,每次源代码更改后),那么你就知道是什么导致了错误——这是你最后编辑的内容,所以找到问题并不难。此外,你不必查看所有失败的测试。修复一个会自动修复所有其他测试。
It’s a valid concern, but I don’t see it as a big problem. If you run your tests regularly (ideally, after each source code change), then you know what caused the bug—it’s what you edited last, so it’s not that difficult to find the issue. Also, you don’t have to look at all the failing tests. Fixing one automatically fixes all the others.
此外,故障在整个测试套件中级联也具有一定价值。如果一个错误不仅导致一个测试出现故障,而且导致很多测试出现故障,则表明您刚刚破坏的那段代码具有重要价值 — 整个系统都依赖于它。在处理代码时,请记住这些信息。
Furthermore, there’s some value in failures cascading all over the test suite. If a bug leads to a fault in not only one test but a whole lot of them, it shows that the piece of code you have just broken is of great value—the entire system depends on it. That’s useful information to keep in mind when working with the code.
古典学派和伦敦学派之间剩下的两个区别是
Two remaining differences between the classical and London schools are
测试驱动开发是一种软件开发过程,它依靠测试来推动项目开发。该过程包括三个阶段(有些作者指定为四个阶段),每个测试用例都要重复这些阶段:
Test-driven development is a software development process that relies on tests to drive the project development. The process consists of three (some authors specify four) stages, which you repeat for every test case:
关于这个主题的很好的资料来源是我之前推荐的两本书:Kent Beck 的《测试驱动开发:通过示例》和Steve Freeman 和 Nat Pryce 的《成长的面向对象软件,以测试为指导》 。
Good sources on this topic are the two books I recommended earlier: Kent Beck’s Test-Driven Development: By Example, and Growing Object-Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce.
伦敦风格的单元测试导致了由外而内的 TDD,即从设定整个系统期望的更高级别的测试开始。通过使用模拟,您可以指定系统应与哪些协作者进行通信以实现预期结果。然后,您可以逐步完成类图,直到实现每个类。模拟使这一设计过程成为可能,因为您可以专注于一个每次只实现一个类。你可以在测试 SUT 时切断所有协作者,从而将这些协作者的实现推迟到以后。
The London style of unit testing leads to outside-in TDD, where you start from the higher-level tests that set expectations for the whole system. By using mocks, you specify which collaborators the system should communicate with to achieve the expected result. You then work your way through the graph of classes until you implement every one of them. Mocks make this design process possible because you can focus on one class at a time. You can cut off all of the SUT’s collaborators when testing it and thus postpone implementing those collaborators to a later time.
传统学派并没有提供完全相同的指导,因为您必须在测试中处理真实对象。相反,您通常使用由内而外的方法。在这种风格中,您从领域模型开始,然后在其上添加额外的层,直到软件可供最终用户使用。
The classical school doesn’t provide quite the same guidance since you have to deal with the real objects in tests. Instead, you normally use the inside-out approach. In this style, you start from the domain model and then put additional layers on top of it until the software becomes usable by the end user.
但这两个学派之间最关键的区别在于过度规范的问题:即将测试与 SUT 的实现细节结合起来。与传统风格相比,伦敦风格倾向于产生与实现更紧密结合的测试。这是对普遍使用模拟和一般伦敦风格的主要反对意见。
But the most crucial distinction between the schools is the issue of over-specification: that is, coupling the tests to the SUT’s implementation details. The London style tends to produce tests that couple to the implementation more often than the classical style. And this is the main objection against the ubiquitous use of mocks and the London style in general.
关于模拟的话题还有很多。从第 4 章开始,我将逐步介绍与之相关的所有内容。
There’s much more to the topic of mocking. Starting with chapter 4, I gradually cover everything related to it.
伦敦学派和古典学派对融合测试的定义也存在分歧。这种分歧自然源于他们对隔离问题的不同看法。
The London and classical schools also diverge in their definition of an integration test. This disagreement flows naturally from the difference in their views on the isolation issue.
伦敦学派认为,任何使用真实协作对象的测试都是集成测试。伦敦学派的支持者认为,大多数以传统风格编写的测试都是集成测试。例如,参见清单 1.4,其中我首先介绍了两个涵盖客户购买功能的测试。从传统角度来看,该代码是典型的单元测试,但对于伦敦学派的追随者来说,这是一个集成测试。
The London school considers any test that uses a real collaborator object an integration test. Most of the tests written in the classical style would be deemed integration tests by the London school proponents. For an example, see listing 1.4, in which I first introduced the two tests covering the customer purchase functionality. That code is a typical unit test from the classical perspective, but it’s an integration test for a follower of the London school.
在本书中,我使用了单元测试和集成测试的经典定义。再次强调,单元测试是一种自动化测试,具有以下特点:
In this book, I use the classical definitions of both unit and integration testing. Again, a unit test is an automated test that has the following characteristics:
现在我已经澄清了第一和第三个属性的含义,我将从古典学派的角度重新定义它们。单元测试是一种
Now that I’ve clarified what the first and third attributes mean, I’ll redefine them from the point of view of the classical school. A unit test is a test that
那么,集成测试就是不符合这些标准之一的测试。例如,涉及共享依赖项(例如数据库)的测试不能独立于其他测试运行。如果并行运行,一个测试引入的数据库状态更改将改变依赖于同一数据库的所有其他测试的结果。您必须采取额外措施来避免这种干扰。特别是,您必须按顺序运行此类测试,以便每个测试都等待轮到它处理共享依赖项。
An integration test, then, is a test that doesn’t meet one of these criteria. For example, a test that reaches out to a shared dependency—say, a database—can’t run in isolation from other tests. A change in the database’s state introduced by one test would alter the outcome of all other tests that rely on the same database if run in parallel. You’d have to take additional steps to avoid this interference. In particular, you would have to run such tests sequentially, so that each test would wait its turn to work with the shared dependency.
同样,对进程外依赖项的扩展也会使测试变慢。对数据库的调用会增加数百毫秒甚至可能长达一秒的额外执行时间。几毫秒乍一看似乎不是什么大问题,但当你的测试套件变得足够大时,每一秒都很重要。
Similarly, an outreach to an out-of-process dependency makes the test slow. A call to a database adds hundreds of milliseconds, potentially up to a second, of additional execution time. Milliseconds might not seem like a big deal at first, but when your test suite grows large enough, every second counts.
理论上,您可以编写一个只适用于内存中对象的慢速测试,但这并不容易。同一内存空间内的对象之间的通信比不同进程之间的通信成本要低得多。即使测试适用于数百个内存中对象,与它们的通信仍将比调用数据库更快。
In theory, you could write a slow test that works with in-memory objects only, but it’s not that easy to do. Communication between objects inside the same memory space is much less expensive than between separate processes. Even if the test works with hundreds of in-memory objects, the communication with them will still execute faster than a call to a database.
最后,当测试验证两个或更多行为单元时,它就是集成测试。这通常是尝试优化测试套件的执行速度的结果。当您有两个慢速测试遵循类似的步骤但验证不同的行为单元时,将它们合并为一个可能是有意义的:一个测试检查两个类似的东西比两个更细粒度的测试运行得更快。但话又说回来,两个原始测试已经是集成测试了(因为它们很慢),所以这个特性通常不是决定性的。
Finally, a test is an integration test when it verifies two or more units of behavior. This is often a result of trying to optimize the test suite’s execution speed. When you have two slow tests that follow similar steps but verify different units of behavior, it might make sense to merge them into one: one test checking two similar things runs faster than two more-granular tests. But then again, the two original tests would have been integration tests already (due to them being slow), so this characteristic usually isn’t decisive.
集成测试还可以验证由不同团队开发的两个或多个模块如何协同工作。这也属于第三类测试,即一次验证多个行为单元。但同样,由于这种集成通常需要进程外依赖关系,因此测试将无法满足所有三个标准,而不仅仅是一个标准。
An integration test can also verify how two or more modules developed by separate teams work together. This also falls into the third bucket of tests that verify multiple units of behavior at once. But again, because such an integration normally requires an out-of-process dependency, the test will fail to meet all three criteria, not just one.
集成测试通过验证整个系统,在软件质量方面发挥着重要作用。我在第 3 部分详细介绍了集成测试。
Integration testing plays a significant part in contributing to software quality by verifying the system as a whole. I write about integration testing in detail in part 3.
简而言之,集成测试是一种测试,用于验证您的代码是否与共享依赖项、进程外依赖项或组织中其他团队开发的代码集成工作。还有一个单独的端到端测试概念。端到端测试是集成测试的一个子集。它们也会检查您的代码如何与进程外依赖项一起工作。端到端测试和集成测试之间的区别在于,端到端测试通常包含更多此类依赖项。
In short, an integration test is a test that verifies that your code works in integration with shared dependencies, out-of-process dependencies, or code developed by other teams in the organization. There’s also a separate notion of an end-to-end test. End-to-end tests are a subset of integration tests. They, too, check to see how your code works with out-of-process dependencies. The difference between an end-to-end test and an integration test is that end-to-end tests usually include more of such dependencies.
有时,这条界线很模糊,但一般来说,集成测试只适用于一两个进程外依赖项。另一方面,端到端测试适用于所有进程外依赖项,或绝大多数依赖项。因此,它被称为端到端,这意味着测试从最终用户的角度验证系统,包括该系统集成的所有外部应用程序(见图2.6)。
The line is blurred at times, but in general, an integration test works with only one or two out-of-process dependencies. On the other hand, an end-to-end test works with all out-of-process dependencies, or with the vast majority of them. Hence the name end-to-end, which means the test verifies the system from the end user’s point of view, including all the external applications this system integrates with (see figure 2.6).
人们还使用诸如UI 测试(UI 代表用户界面)、GUI 测试(GUI 代表图形用户界面)和功能测试之类的术语。这些术语定义不明确,但一般来说,这些术语都是同义词。
People also use such terms as UI tests (UI stands for user interface), GUI tests (GUI is graphical user interface), and functional tests. The terminology is ill-defined, but in general, these terms are all synonyms.
假设您的应用程序使用三个进程外依赖项:数据库、文件系统和支付网关。典型的集成测试将仅包括数据库和文件系统,并使用测试替身来替换支付网关。这是因为您可以完全控制数据库和文件系统,因此可以轻松地在测试中将它们带到所需状态,而您对支付网关的控制程度却不一样。对于支付网关,您可能需要联系支付处理机构来设置一个特殊的测试帐户。您可能还需要不时检查该帐户,以手动清理过去测试执行中遗留的所有支付费用。
Let’s say your application works with three out-of-process dependencies: a database, the file system, and a payment gateway. A typical integration test would include only the database and file system in scope and use a test double to replace the payment gateway. That’s because you have full control over the database and file system, and thus can easily bring them to the required state in tests, whereas you don’t have the same degree of control over the payment gateway. With the payment gateway, you may need to contact the payment processor organization to set up a special test account. You might also need to check that account from time to time to manually clean up all the payment charges left over from the past test executions.
由于端到端测试在维护方面是最昂贵的,因此最好在构建过程的后期运行它们,即在所有单元和集成测试都通过之后。您甚至可能只在构建服务器上运行它们,而不是在单个开发人员的机器上运行它们。
Since end-to-end tests are the most expensive in terms of maintenance, it’s better to run them late in the build process, after all the unit and integration tests have passed. You may possibly even run them only on the build server, not on individual developers’ machines.
请记住,即使使用端到端测试,您也可能无法解决所有进程外依赖项。某些依赖项可能没有测试版本,或者可能无法自动将这些依赖项置于所需状态。因此,您可能仍需要使用测试替身,这进一步证明了集成测试和端到端测试之间没有明显的界限。
Keep in mind that even with end-to-end tests, you might not be able to tackle all of the out-of-process dependencies. There may be no test version of some dependencies, or it may be impossible to bring those dependencies to the required state automatically. So you may still need to use a test double, reinforcing the fact that there isn’t a distinct line between integration and end-to-end tests.
在第 1 部分的剩余章节中,我将带您回顾一些基本主题。我将介绍典型单元测试的结构,该结构通常由安排、操作和断言(AAA) 模式表示。我还将展示我选择的单元测试框架 xUnit,并解释为什么我使用它而不是它的竞争对手之一。
In this remaining chapter of part 1, I’ll give you a refresher on some basic topics. I’ll go over the structure of a typical unit test, which is usually represented by the arrange, act, and assert (AAA) pattern. I’ll also show the unit testing framework of my choice—xUnit—and explain why I’m using it and not one of its competitors.
在此过程中,我们将讨论单元测试的命名。关于这个主题有很多相互竞争的建议,不幸的是,其中大多数都不能很好地改善您的单元测试。在本章中,我将描述那些不太有用的命名实践,并说明为什么它们通常不是最佳选择。与这些实践不同,我为您提供了一种替代方法 - 一种简单、易于遵循的测试命名指南,它不仅对编写测试的程序员来说易于理解,而且对熟悉问题领域的任何其他人来说也易于理解。
Along the way, we’ll talk about naming unit tests. There are quite a few competing pieces of advice on this topic, and unfortunately, most of them don’t do a good enough job improving your unit tests. In this chapter, I describe those less-useful naming practices and show why they usually aren’t the best choice. Instead of those practices, I give you an alternative—a simple, easy-to-follow guideline for naming tests in a way that makes them readable not only to the programmer who wrote them, but also to any other person familiar with the problem domain.
最后,我将讨论该框架的一些有助于简化单元测试流程的功能。不要担心这些信息过于具体到 C#和 .NET;大多数单元测试框架都具有类似的功能,无论使用哪种编程语言。如果您学习了其中一种,那么使用另一种也不会有问题。
Finally, I’ll talk about some features of the framework that help streamline the process of unit testing. Don’t worry about this information being too specific to C# and .NET; most unit testing frameworks exhibit similar functionality, regardless of the programming language. If you learn one of them, you won’t have problems working with another.
本节介绍如何使用安排、操作和断言模式来构建单元测试、应避免哪些陷阱以及如何使测试尽可能地易于阅读。
This section shows how to structure unit tests using the arrange, act, and assert pattern, what pitfalls to avoid, and how to make your tests as readable as possible.
AAA 模式主张将每个测试分为三个部分:arrange、act和assert。(这种模式有时也称为3A 模式。)我们来看一个Calculator只有一个方法的类,该方法计算两个数字的总和:
The AAA pattern advocates for splitting each test into three parts: arrange, act, and assert. (This pattern is sometimes also called the 3A pattern.) Let’s take a Calculator class with a single method that calculates a sum of two numbers:
公共课计算器
{
公共双精度总和(双精度第一个,双精度第二个)
{
返回第一+第二;
}
}public class Calculator
{
public double Sum(double first, double second)
{
return first + second;
}
}
以下清单显示了验证类行为的测试。此测试遵循 AAA 模式。
The following listing shows a test that verifies the class’s behavior. This test follows the AAA pattern.
公共类计算器测试 1
{
[事实] 2
public void Sum_of_two_numbers() 3
{
// 安排
double first = 10; 4
double second = 20; 4
var calculator = new Calculator(); 4
// 行为
双精度结果 = 计算器.Sum(第一,第二); 5
// 断言
断言.Equal(30,结果); 6
}
}public class CalculatorTests 1
{
[Fact] 2
public void Sum_of_two_numbers() 3
{
// Arrange
double first = 10; 4
double second = 20; 4
var calculator = new Calculator(); 4
// Act
double result = calculator.Sum(first, second); 5
// Assert
Assert.Equal(30, result); 6
}
}
AAA 模式为套件中的所有测试提供了简单、统一的结构。这种统一性是此模式的最大优势之一:一旦您习惯了它,就可以轻松阅读和理解任何测试。这反过来又降低了整个测试套件的维护成本。结构如下:
The AAA pattern provides a simple, uniform structure for all tests in the suite. This uniformity is one of the biggest advantages of this pattern: once you get used to it, you can easily read and understand any test. That, in turn, reduces maintenance costs for your entire test suite. The structure is as follows:
您可能听说过Given-When-Then模式,它与 AAA 类似。此模式也主张将测试分为三个部分:
You might have heard of the Given-When-Then pattern, which is similar to AAA. This pattern also advocates for breaking the test down into three parts:
两种模式在测试组成上没有区别。唯一的区别是 Given-When-Then 结构对非程序员来说更易读。因此 Given-When-Then 更适合与非技术人员共享的测试。
There’s no difference between the two patterns in terms of the test composition. The only distinction is that the Given-When-Then structure is more readable to non-programmers. Thus, Given-When-Then is more suitable for tests that are shared with non-technical people.
自然倾向是从“安排”部分开始编写测试。毕竟,它在其他两个部分之前。这种方法在绝大多数情况下都很有效,但从断言部分开始也是一个可行的选择。当您实践测试驱动开发 (TDD) 时(即在开发功能之前创建失败测试时),您对该功能的行为还不够了解。因此,首先概述您对行为的期望,然后弄清楚如何开发系统以满足这一期望,这是很有利的。
The natural inclination is to start writing a test with the arrange section. After all, it comes before the other two. This approach works well in the vast majority of cases, but starting with the assert section is a viable option too. When you practice Test-Driven Development (TDD)—that is, when you create a failing test before developing a feature—you don’t know enough about the feature’s behavior yet. So, it becomes advantageous to first outline what you expect from the behavior and then figure out how to develop the system to meet this expectation.
这种技术可能看起来违反直觉,但这就是我们解决问题的方法。我们首先考虑目标:特定行为应该为我们做什么。之后才是真正的解决问题。在一切之前写下断言只是这种思考过程的形式化。但同样,此准则仅适用于遵循 TDD 的情况——即在编写生产代码之前编写测试。如果您在测试之前编写生产代码,那么在进行测试时,您已经知道对行为的期望,因此从安排部分开始是更好的选择。
Such a technique may look counterintuitive, but it’s how we approach problem solving. We start by thinking about the objective: what a particular behavior should to do for us. The actual solving of the problem comes after that. Writing down the assertions before everything else is merely a formalization of this thinking process. But again, this guideline is only applicable when you follow TDD—when you write a test before the production code. If you write the production code before the test, by the time you move on to the test, you already know what to expect from the behavior, so starting with the arrange section is a better option.
偶尔,你可能会遇到一个包含多个arrange、act或assert部分的测试。它通常如图3.1所示工作。
Occasionally, you may encounter a test with multiple arrange, act, or assert sections. It usually works as shown in figure 3.1.
当您看到多个动作部分被断言部分和(可能还有)排列部分分隔时,这意味着测试验证了多个行为单元。而且,正如我们在第 2 章中讨论的那样,这样的测试不再是单元测试,而是集成测试。最好避免这样的测试结构。单一动作可确保您的测试保持在单元测试的范围内,这意味着它们简单、快速且易于理解。如果您看到一个测试包含一系列动作和断言,请重构它。将每个动作提取到自己的测试中。
When you see multiple act sections separated by assert and, possibly, arrange sections, it means the test verifies multiple units of behavior. And, as we discussed in chapter 2, such a test is no longer a unit test but rather is an integration test. It’s best to avoid such a test structure. A single action ensures that your tests remain within the realm of unit testing, which means they are simple, fast, and easy to understand. If you see a test containing a sequence of actions and assertions, refactor it. Extract each act into a test of its own.
有时在集成测试中拥有多个动作部分是可以的。您可能还记得上一章的内容,集成测试可能很慢。加快速度的一种方法是将多个集成测试组合成一个具有多个动作和断言的测试。当系统状态自然地相互流动时,这种方法尤其有用:即当一个动作同时充当后续动作的安排时。
It’s sometimes fine to have multiple act sections in integration tests. As you may remember from the previous chapter, integration tests can be slow. One way to speed them up is to group several integration tests together into a single test with multiple acts and assertions. It’s especially helpful when system states naturally flow from one another: that is, when an act simultaneously serves as an arrange for the subsequent act.
但同样,这种优化技术仅适用于集成测试 — 并非全部,而是那些已经很慢并且您不希望变得更慢的测试。单元测试或足够快的集成测试不需要这样的优化。将多步骤单元测试拆分成几个测试总是更好的选择。
But again, this optimization technique is only applicable to integration tests—and not all of them, but rather those that are already slow and that you don’t want to become even slower. There’s no need for such an optimization in unit tests or integration tests that are fast enough. It’s always better to split a multistep unit test into several tests.
与多次出现的accordion、act和assert部分类似,您有时可能会遇到带有语句的单元测试if。这也是一种反模式。测试(无论是单元测试还是集成测试)应该是一系列简单的步骤,没有分支。
Similar to multiple occurrences of the arrange, act, and assert sections, you may sometimes encounter a unit test with an if statement. This is also an anti-pattern. A test—whether a unit test or an integration test—should be a simple sequence of steps with no branching.
语句if表明测试一次验证了太多内容。因此,这样的测试应该分成几个测试。但与多个 AAA 部分的情况不同,集成测试也不例外。在测试中进行分支没有任何好处。您只会增加维护成本:if语句使测试更难阅读和理解。
An if statement indicates that the test verifies too many things at once. Such a test, therefore, should be split into several tests. But unlike the situation with multiple AAA sections, there’s no exception for integration tests. There are no benefits in branching within a test. You only gain additional maintenance costs: if statements make the tests harder to read and understand.
人们在开始使用 AAA 模式时经常会问的一个问题是,每个部分应该有多大?那么拆卸部分(测试后清理的部分)又该有多大呢?对于每个测试部分的大小,都有不同的指导原则。
A common question people ask when starting out with the AAA pattern is, how large should each section be? And what about the teardown section—the section that cleans up after the test? There are different guidelines regarding the size for each of the test sections.
安排部分通常是三者中最大的部分。它可以与act和assert部分的总和一样大。但如果它变得比这大得多,最好将安排提取到同一测试类中的私有方法中或单独的工厂类中。两种流行的模式可以帮助您重用安排部分中的代码:Object Mother和Test Data Builder。
The arrange section is usually the largest of the three. It can be as large as the act and assert sections combined. But if it becomes significantly larger than that, it’s better to extract the arrangements either into private methods within the same test class or to a separate factory class. Two popular patterns can help you reuse the code in the arrange sections: Object Mother and Test Data Builder.
act部分通常只有一行代码。如果act包含两行或更多行,则可能表明 SUT 的公共 API 存在问题。
The act section is normally just a single line of code. If the act consists of two or more lines, it could indicate a problem with the SUT’s public API.
最好用一个例子来表达这一点,所以我们从第 2 章中选取一个例子,我将在下面的清单中重复这个例子。在这个例子中,客户在商店购买了商品。
It’s best to express this point with an example, so let’s take one from chapter 2, which I repeat in the following listing. In this example, the customer makes a purchase from a store.
[事实]
public void Purchase_succeeds_when_enough_inventory()
{
// 安排
var store = new Store();
商店.添加库存(产品.洗发水,10);
var 客户 = 新客户();
// 行为
bool 成功 = 顾客.购买(商店, 产品.洗发水, 5);
// 断言
断言.True(成功);
断言.等于(5,商店.获取库存(产品.洗发水));
}[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
// Arrange
var store = new Store();
store.AddInventory(Product.Shampoo, 10);
var customer = new Customer();
// Act
bool success = customer.Purchase(store, Product.Shampoo, 5);
// Assert
Assert.True(success);
Assert.Equal(5, store.GetInventory(Product.Shampoo));
}
请注意,此测试中的act部分是单个方法调用,这是设计良好的类 API 的标志。现在将其与清单 3.3中的版本进行比较:此act部分包含两行。这表明 SUT 存在问题:它要求客户端记住进行第二次方法调用才能完成购买,因此缺乏封装。
Notice that the act section in this test is a single method call, which is a sign of a well-designed class’s API. Now compare it to the version in listing 3.3: this act section contains two lines. And that’s a sign of a problem with the SUT: it requires the client to remember to make the second method call to finish the purchase and thus lacks encapsulation.
[事实]
public void Purchase_succeeds_when_enough_inventory()
{
// 安排
var store = new Store();
商店.添加库存(产品.洗发水,10);
var 客户 = 新客户();
// 行为
bool 成功 = 顾客.购买(商店, 产品.洗发水, 5);
商店.RemoveInventory(成功,Product.Shampoo,5);
// 断言
断言.True(成功);
断言.等于(5,商店.获取库存(产品.洗发水));
}[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
// Arrange
var store = new Store();
store.AddInventory(Product.Shampoo, 10);
var customer = new Customer();
// Act
bool success = customer.Purchase(store, Product.Shampoo, 5);
store.RemoveInventory(success, Product.Shampoo, 5);
// Assert
Assert.True(success);
Assert.Equal(5, store.GetInventory(Product.Shampoo));
}
以下是清单 3.3的act部分的内容:
Here’s what you can read from listing 3.3’s act section:
新版本的问题在于,它需要两次方法调用才能执行单个操作。请注意,这不是测试本身的问题。测试仍然验证相同的行为单元:购买过程。问题在于类的 API 表面Customer。它不应该要求客户端进行额外的方法调用。
The issue with the new version is that it requires two method calls to perform a single operation. Note that this is not an issue with the test itself. The test still verifies the same unit of behavior: the process of making a purchase. The issue lies in the API surface of the Customer class. It shouldn’t require the client to make an additional method call.
从业务角度来看,一次成功的购买会产生两个结果:客户购买产品和商店库存减少。这两个结果必须同时实现,这意味着应该有一个公共方法来同时完成这两个任务。否则,如果客户端代码调用第一个方法而不调用第二个方法,就会出现不一致的情况,在这种情况下,客户将购买产品,但商店中的可用产品数量不会减少。
From a business perspective, a successful purchase has two outcomes: the acquisition of a product by the customer and the reduction of the inventory in the store. Both of these outcomes must be achieved together, which means there should be a single public method that does both things. Otherwise, there’s a room for inconsistency if the client code calls the first method but not the second, in which case the customer will acquire the product but its available amount won’t be reduced in the store.
这种不一致被称为不变性违规。保护代码免受潜在不一致影响的行为称为封装。当不一致渗透到数据库中时,就会成为一个大问题:现在不可能通过简单地重新启动应用程序来重置应用程序的状态。您必须处理数据库中损坏的数据,并可能联系客户并处理这种情况逐案处理。想象一下,如果应用程序生成确认收据但实际上并未保留库存,会发生什么情况。它可能会索要比您近期可获得的库存更多的库存,甚至收取更多费用。
Such an inconsistency is called an invariant violation. The act of protecting your code against potential inconsistencies is called encapsulation. When an inconsistency penetrates into the database, it becomes a big problem: now it’s impossible to reset the state of your application by simply restarting it. You’ll have to deal with the corrupted data in the database and, potentially, contact customers and handle the situation on a case-by-case basis. Just imagine what would happen if the application generated confirmation receipts without actually reserving the inventory. It might issue claims to, and even charge for, more inventory than you could feasibly acquire in the near future.
补救措施是始终保持代码封装。在上例中,客户应将所购库存从商店中移除作为其Purchase方法的一部分,而不是依赖客户端代码来执行此操作。在维护不变量时,应消除可能导致不变量违规的任何潜在操作。
The remedy is to maintain code encapsulation at all times. In the previous example, the customer should remove the acquired inventory from the store as part of its Purchase method and not rely on the client code to do so. When it comes to maintaining invariants, you should eliminate any potential course of action that could lead to an invariant violation.
将act部分保持在一行的指导原则适用于绝大多数包含业务逻辑的代码,但对于实用程序或基础结构代码则不那么适用。因此,我不会说“永远不要这样做”。不过,一定要检查每个这样的情况,看看是否有潜在的封装漏洞。
This guideline of keeping the act section down to a single line holds true for the vast majority of code that contains business logic, but less so for utility or infrastructure code. Thus, I won’t say “never do it.” Be sure to examine each such case for a potential breach in encapsulation, though.
最后,还有断言部分。您可能听说过每个测试只有一个断言的准则。它植根于上一章讨论的前提:以尽可能最小的代码片段为目标。
Finally, there’s the assert section. You may have heard about the guideline of having one assertion per test. It takes root in the premise discussed in the previous chapter: the premise of targeting the smallest piece of code possible.
正如您所知,这个前提是错误的。单元测试中的单元是行为单元,而不是代码单元。单个行为单元可以表现出多种结果,并且可以在一次测试中评估所有结果。
As you already know, this premise is incorrect. A unit in unit testing is a unit of behavior, not a unit of code. A single unit of behavior can exhibit multiple outcomes, and it’s fine to evaluate them all in one test.
话虽如此,您需要注意断言部分是否变得过大:这可能是生产代码中缺少抽象的迹象。例如,与其断言 SUT 返回的对象中的所有属性,不如在对象的类中定义适当的相等成员。然后,您可以使用单个断言将对象与预期值进行比较。
Having that said, you need to watch out for assertion sections that grow too large: it could be a sign of a missing abstraction in the production code. For example, instead of asserting all properties inside an object returned by the SUT, it may be better to define proper equality members in the object’s class. You can then compare the object to an expected value using a single assertion.
有些人还区分了第四个部分,即拆卸,它位于排列、动作和断言之后。例如,您可以使用此部分删除测试创建的任何文件、关闭数据库连接等等。拆卸通常由一个单独的方法表示,该方法在类中的所有测试中重复使用。因此,我没有将此阶段包含在 AAA 模式中。
Some people also distinguish a fourth section, teardown, which comes after arrange, act, and assert. For example, you can use this section to remove any files created by the test, close a database connection, and so on. The teardown is usually represented by a separate method, which is reused across all tests in the class. Thus, I don’t include this phase in the AAA pattern.
请注意,大多数单元测试不需要拆卸。单元测试不会与进程外依赖项进行对话,因此不会留下需要处理的副作用。这是集成测试的领域。我们将在第 3 部分中进一步讨论如何在集成测试后正确清理。
Note that most unit tests don’t need teardown. Unit tests don’t talk to out-of-process dependencies and thus don’t leave side effects that need to be disposed of. That’s a realm of integration testing. We’ll talk more about how to properly clean up after integration tests in part 3.
SUT 在测试中扮演着重要的角色。它为您想要在应用程序中调用的行为提供了一个入口点。正如我们在上一章中讨论的那样,此行为可以跨越多达几个类,也可以跨越少至一个方法。但只能有一个入口点:一个触发该行为的类。
The SUT plays a significant role in tests. It provides an entry point for the behavior you want to invoke in the application. As we discussed in the previous chapter, this behavior can span across as many as several classes or as little as a single method. But there can be only one entry point: one class that triggers that behavior.
因此,区分 SUT 和其依赖项非常重要,尤其是当依赖项很多时,这样您就不需要花太多时间弄清楚测试中的谁是谁。为此,请始终在测试中命名 SUT sut。以下清单显示了CalculatorTests重命名Calculator实例后的样子。
Thus it’s important to differentiate the SUT from its dependencies, especially when there are quite a few of them, so that you don’t need to spend too much time figuring out who is who in the test. To do that, always name the SUT in tests sut. The following listing shows how CalculatorTests would look after renaming the Calculator instance.
公共类计算器测试
{
[事实]
公共无效两个数字之和()
{
// 安排
双倍第一=10;
双秒=20;
var sut = new Calculator(); 1
// 行为
双精度结果 = sut.Sum(第一,第二);
// 断言
断言.等于(30,结果);
}
}public class CalculatorTests
{
[Fact]
public void Sum_of_two_numbers()
{
// Arrange
double first = 10;
double second = 20;
var sut = new Calculator(); 1
// Act
double result = sut.Sum(first, second);
// Assert
Assert.Equal(30, result);
}
}
区分 SUT 和其依赖项很重要,区分这三个部分也很重要,这样您就不必花太多时间弄清楚测试中的某一行属于哪个部分。一种方法是在每个部分的开头前加上// Arrange、// Act和// Assert注释。另一种方法是用空行分隔各部分,如下所示。
Just as it’s important to set the SUT apart from its dependencies, it’s also important to differentiate the three sections from each other, so that you don’t spend too much time figuring out what section a particular line in the test belongs to. One way to do that is to put // Arrange, // Act, and // Assert comments before the beginning of each section. Another way is to separate the sections with empty lines, as shown next.
公共类计算器测试
{
[事实]
公共无效两个数字之和()
{
double first = 10; 1
double second = 20; 1
var sut = new Calculator(); 1
double 结果 = sut.Sum(第一,第二); 2
断言.Equal(30,结果); 3
}
}public class CalculatorTests
{
[Fact]
public void Sum_of_two_numbers()
{
double first = 10; 1
double second = 20; 1
var sut = new Calculator(); 1
double result = sut.Sum(first, second); 2
Assert.Equal(30, result); 3
}
}
在大多数单元测试中,用空行分隔部分效果很好。它允许您在简洁性和可读性之间保持平衡。但在大型测试中,它效果不佳,因为您可能希望在安排部分内放置额外的空行来区分配置阶段。集成测试中经常出现这种情况 - 它们经常包含复杂的设置逻辑。因此,
Separating sections with empty lines works great in most unit tests. It allows you to keep a balance between brevity and readability. It doesn’t work as well in large tests, though, where you may want to put additional empty lines inside the arrange section to differentiate between configuration stages. This is often the case in integration tests—they frequently contain complicated setup logic. Therefore,
在本节中,我将简要介绍 .NET 中可用的单元测试工具及其功能。我使用 xUnit ( https://github.com/xunit/xunit ) 作为单元测试框架(请注意,您需要安装xunit.runner.visualstudioNuGet 包才能从 Visual Studio 运行 xUnit 测试)。虽然此框架仅适用于 .NET,但每种面向对象语言(Java、C++、JavaScript 等)都有单元测试框架,并且所有这些框架看起来都非常相似。如果您使用过其中一种,那么使用另一种也不会有任何问题。
In this section, I give a brief overview of unit testing tools available in .NET, and their features. I’m using xUnit (https://github.com/xunit/xunit) as the unit testing framework (note that you need to install the xunit.runner.visualstudio NuGet package in order to run xUnit tests from Visual Studio). Although this framework works in .NET only, every object-oriented language (Java, C++, JavaScript, and so on) has unit testing frameworks, and all those frameworks look quite similar to each other. If you’ve worked with one of them, you won’t have any issues working with another.
仅在 .NET 中,就有几种替代方案可供选择,例如 NUnit ( https://github.com/nunit/nunit ) 和内置的 Microsoft MSTest。我个人更喜欢 xUnit,原因我将在后面描述,但您也可以使用 NUnit;这两个框架在功能方面几乎不相上下。但我不推荐 MSTest;它没有提供与 xUnit 和 NUnit 相同级别的灵活性。而且不要相信我的话——即使是微软内部的人也不会使用 MSTest。例如,ASP.NET Core 团队使用 xUnit。
In .NET alone, there are several alternatives to choose from, such as NUnit (https://github.com/nunit/nunit) and the built-in Microsoft MSTest. I personally prefer xUnit for the reasons I’ll describe shortly, but you can also use NUnit; these two frameworks are pretty much on par in terms of functionality. I don’t recommend MSTest, though; it doesn’t provide the same level of flexibility as xUnit and NUnit. And don’t take my word for it—even people inside Microsoft refrain from using MSTest. For example, the ASP.NET Core team uses xUnit.
我更喜欢 xUnit,因为它是 NUnit 的一个更简洁、更简洁的版本。例如,您可能已经注意到,在我迄今为止提出的测试中,除了 之外没有与框架相关的属性[Fact],这将该方法标记为单元测试,因此单元测试框架知道要运行它。没有[TestFixture]属性;任何公共类都可以包含单元测试。也没有[SetUp]或[TearDown]。如果您需要在测试之间共享配置逻辑,您可以将其放在构造函数中。如果您需要清理某些东西,您可以实现IDisposable接口,如本清单所示。
I prefer xUnit because it’s a cleaner, more concise version of NUnit. For example, you may have noticed that in the tests I’ve brought up so far, there are no framework-related attributes other than [Fact], which marks the method as a unit test so the unit testing framework knows to run it. There are no [TestFixture] attributes; any public class can contain a unit test. There’s also no [SetUp] or [TearDown]. If you need to share configuration logic between tests, you can put it inside the constructor. And if you need to clean something up, you can implement the IDisposable interface, as shown in this listing.
公共类计算器测试:IDisposable
{
私人只读计算器_sut;
公共计算器测试() 1
{ 1
_sut = 新计算器(); 1
} 1
[事实]
公共无效两个数字之和()
{
/* ... */
}
公共无效Dispose() 2
{ 2
_sut.CleanUp(); 2
} 2
}public class CalculatorTests : IDisposable
{
private readonly Calculator _sut;
public CalculatorTests() 1
{ 1
_sut = new Calculator(); 1
} 1
[Fact]
public void Sum_of_two_numbers()
{
/* ... */
}
public void Dispose() 2
{ 2
_sut.CleanUp(); 2
} 2
}
如您所见,xUnit 的作者在简化框架方面迈出了重要一步。许多以前需要额外配置的概念(如[TestFixture]或[SetUp]属性)现在依赖于约定或内置语言结构。
As you can see, the xUnit authors took significant steps toward simplifying the framework. A lot of notions that previously required additional configuration (like [TestFixture] or [SetUp] attributes) now rely on conventions or built-in language constructs.
我特别喜欢该[Fact]属性,特别是因为它被称为Fact而不是Test。它强调了我在上一章中提到的经验法则:每个测试都应该讲述一个故事。这个故事是关于问题域的单独、原子场景或事实,通过的测试证明这个场景或事实成立。如果测试失败,则意味着要么故事不再有效,您需要重写它,要么系统本身必须修复。
I particularly like the [Fact] attribute, specifically because it’s called Fact and not Test. It emphasizes the rule of thumb I mentioned in the previous chapter: each test should tell a story. This story is an individual, atomic scenario or fact about the problem domain, and the passing test is a proof that this scenario or fact holds true. If the test fails, it means either the story is no longer valid and you need to rewrite it, or the system itself has to be fixed.
我鼓励您在编写单元测试时采用这种思维方式。您的测试不应该只是对生产代码功能的枯燥列举。相反,它们应该提供应用程序行为的更高层次的描述。理想情况下,这种描述不仅对程序员有意义,而且对业务人员也有意义。
I encourage you to adopt this way of thinking when you write unit tests. Your tests shouldn’t be a dull enumeration of what the production code does. Rather, they should provide a higher-level description of the application’s behavior. Ideally, this description should be meaningful not just to programmers but also to business people.
了解如何以及何时在测试之间重用代码非常重要。在安排部分之间重用代码是缩短和简化测试的好方法,本节将展示如何正确做到这一点。
It’s important to know how and when to reuse code between tests. Reusing code between arrange sections is a good way to shorten and simplify your tests, and this section shows how to do that properly.
我之前提到过,装置布置通常占用太多空间。将这些布置提取到单独的方法或类中,然后在测试之间重复使用,这是很有意义的。有两种方法可以执行此类重复使用,但只有其中一种是有益的;另一种则会导致维护成本增加。
I mentioned earlier that often, fixture arrangements take up too much space. It makes sense to extract these arrangements into separate methods or classes that you then reuse between tests. There are two ways you can perform such reuse, but only one of them is beneficial; the other leads to increased maintenance costs.
The term test fixture has two common meanings:
我在整本书中使用第一个定义。
I use the first definition throughout this book.
重用测试装置的第一个(错误的)方法是在测试的构造函数中初始化它们([SetUp]如果使用 NUnit,则在标有属性的方法中),如下所示。
The first—incorrect—way to reuse test fixtures is to initialize them in the test’s constructor (or the method marked with a [SetUp] attribute if you are using NUnit), as shown next.
公共类客户测试
{
私人只读商店 _store; 1
私人只读客户_sut;
公共客户测试() 2
{ 2
_store = 新商店(); 2
_store.AddInventory(Product.Shampoo,10); 2
_sut = 新客户(); 2
} 2
[事实]
public void Purchase_succeeds_when_enough_inventory()
{
bool 成功 = _sut.Purchase(_store, Product.Shampoo, 5);
断言.True(成功);
断言.等于(5,_store.GetInventory(产品.洗发水));
}
[事实]
public void Purchase_fails_when_not_enough_inventory()
{
bool 成功 = _sut.Purchase(_store, Product.Shampoo, 15);
断言.False(成功);
断言.等于(10,_store.GetInventory(产品.洗发水));
}
}public class CustomerTests
{
private readonly Store _store; 1
private readonly Customer _sut;
public CustomerTests() 2
{ 2
_store = new Store(); 2
_store.AddInventory(Product.Shampoo, 10); 2
_sut = new Customer(); 2
} 2
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
bool success = _sut.Purchase(_store, Product.Shampoo, 5);
Assert.True(success);
Assert.Equal(5, _store.GetInventory(Product.Shampoo));
}
[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
bool success = _sut.Purchase(_store, Product.Shampoo, 15);
Assert.False(success);
Assert.Equal(10, _store.GetInventory(Product.Shampoo));
}
}
清单 3.7中的两个测试具有共同的配置逻辑。事实上,它们的安排部分是相同的,因此可以完全提取到的CustomerTests构造函数中——这正是我在这里所做的。测试本身不再包含安排。
The two tests in listing 3.7 have common configuration logic. In fact, their arrange sections are the same and thus can be fully extracted into CustomerTests’s constructor—which is precisely what I did here. The tests themselves no longer contain arrangements.
通过这种方法,您可以显著减少测试代码量——您可以摆脱测试中的大部分甚至所有测试装置配置。但这种方法有两个明显的缺点:
With this approach, you can significantly reduce the amount of test code—you can get rid of most or even all test fixture configurations in tests. But this technique has two significant drawbacks:
让我们更详细地讨论这些缺点。
Let’s discuss these drawbacks in more detail.
在新版本中,如清单 3.7所示,所有测试都相互耦合:修改一个测试的排列逻辑将影响类中的所有测试。例如,更改此行
In the new version, shown in listing 3.7, all tests are coupled to each other: a modification of one test’s arrangement logic will affect all tests in the class. For example, changing this line
_store.添加库存(产品.洗发水,10);
_store.AddInventory(Product.Shampoo, 10);
对此
to this
_store.添加库存(产品.洗发水,15);
_store.AddInventory(Product.Shampoo, 15);
会使测试关于商店初始状态的假设无效,从而导致不必要的测试失败。
would invalidate the assumption the tests make about the store’s initial state and therefore would lead to unnecessary test failures.
这违反了一条重要准则:修改一个测试不应影响其他测试。这条准则与我们在第 2 章中讨论的类似——测试应彼此独立运行。但这并不相同。这里我们讨论的是独立修改测试,而不是独立执行。两者都是设计良好的测试的重要属性。
That’s a violation of an important guideline: a modification of one test should not affect other tests. This guideline is similar to what we discussed in chapter 2—that tests should run in isolation from each other. It’s not the same, though. Here, we are talking about independent modification of tests, not independent execution. Both are important attributes of a well-designed test.
为了遵循此准则,您需要避免在测试类中引入共享状态。这两个私有字段就是此类共享状态的示例:
To follow this guideline, you need to avoid introducing shared state in test classes. These two private fields are examples of such a shared state:
私人只读商店_store; 私人只读客户_sut;
private readonly Store _store; private readonly Customer _sut;
将安排代码提取到构造函数中的另一个缺点是降低了测试的可读性。您不再能仅通过查看测试本身来了解全貌。您必须检查类中的不同位置才能了解测试方法的作用。
The other drawback to extracting the arrangement code into the constructor is diminished test readability. You no longer see the full picture just by looking at the test itself. You have to examine different places in the class to understand what the test method does.
即使没有太多的安排逻辑——比如,只有设备的实例化——你还是最好直接把它移到测试方法中。否则,你会怀疑如果它真的只是实例化或在那里配置了其他东西,也是如此。独立的测试不会给你留下这样的不确定性。
Even if there’s not much arrangement logic—say, only instantiation of the fixtures—you are still better off moving it directly to the test method. Otherwise, you’ll wonder if it’s really just instantiation or something else being configured there, too. A self-contained test doesn’t leave you with such uncertainties.
在重用测试装置时,使用构造函数并不是最好的方法。第二种方法(也是最有益的方法)是在测试类中引入私有工厂方法,如下面的清单所示。
The use of the constructor is not the best approach when it comes to reusing test fixtures. The second way—the beneficial one—is to introduce private factory methods in the test class, as shown in the following listing.
公共类客户测试
{
[事实]
public void Purchase_succeeds_when_enough_inventory()
{
商店 store = CreateStoreWithInventory(Product.Shampoo, 10);
客户 sut = CreateCustomer();
bool success = sut.Purchase(store, Product.Shampoo, 5);
断言.True(成功);
断言.等于(5,商店.获取库存(产品.洗发水));
}
[事实]
public void Purchase_fails_when_not_enough_inventory()
{
商店 store = CreateStoreWithInventory(Product.Shampoo, 10);
客户 sut = CreateCustomer();
bool success = sut.Purchase(store, Product.Shampoo, 15);
断言.False(成功);
断言.等于(10,商店.获取库存(产品.洗发水));
}
私人商店 CreateStoreWithInventory(
产品product,int数量)
{
商店 store = 新商店();
商店.添加库存(产品,数量);
返回商店;
}
私有静态客户创建客户()
{
返回新客户();
}
}public class CustomerTests
{
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
Store store = CreateStoreWithInventory(Product.Shampoo, 10);
Customer sut = CreateCustomer();
bool success = sut.Purchase(store, Product.Shampoo, 5);
Assert.True(success);
Assert.Equal(5, store.GetInventory(Product.Shampoo));
}
[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
Store store = CreateStoreWithInventory(Product.Shampoo, 10);
Customer sut = CreateCustomer();
bool success = sut.Purchase(store, Product.Shampoo, 15);
Assert.False(success);
Assert.Equal(10, store.GetInventory(Product.Shampoo));
}
private Store CreateStoreWithInventory(
Product product, int quantity)
{
Store store = new Store();
store.AddInventory(product, quantity);
return store;
}
private static Customer CreateCustomer()
{
return new Customer();
}
}
通过将公共初始化代码提取到私有工厂方法中,您还可以缩短测试代码,但同时保留正在发生的事情的完整上下文在测试中。此外,只要你让私有方法足够通用,它们就不会将测试相互耦合。也就是说,允许测试指定它们希望如何创建装置。
By extracting the common initialization code into private factory methods, you can also shorten the test code, but at the same time keep the full context of what’s going on in the tests. Moreover, the private methods don’t couple tests to each other as long as you make them generic enough. That is, allow the tests to specify how they want the fixtures to be created.
例如,看一下这一行:
Look at this line, for example:
商店 store = CreateStoreWithInventory(Product.Shampoo, 10);
Store store = CreateStoreWithInventory(Product.Shampoo, 10);
测试明确指出它希望工厂方法向商店添加 10 单位洗发水。这既具有高度的可读性,又具有可重用性。它具有可读性,因为您无需检查工厂方法的内部结构即可了解所创建商店的属性。它具有可重用性,因为您也可以在其他测试中使用此方法。
The test explicitly states that it wants the factory method to add 10 units of shampoo to the store. This is both highly readable and reusable. It’s readable because you don’t need to examine the internals of the factory method to understand the attributes of the created store. It’s reusable because you can use this method in other tests, too.
注意,在这个特定的例子中,不需要引入工厂方法,因为安排逻辑非常简单。仅将其视为一个演示。
Note that in this particular example, there’s no need to introduce factory methods, as the arrangement logic is quite simple. View it merely as a demonstration.
重复使用测试装置的规则有一个例外。如果所有或几乎所有测试都使用装置,则可以在构造函数中实例化装置。对于使用数据库的集成测试,通常就是这种情况。所有此类测试都需要数据库连接,您可以初始化一次,然后在任何地方重复使用。但即便如此,引入基类并在该类的构造函数中初始化数据库连接会更有意义,而不是在单个测试类中初始化。请参阅以下清单,了解基类中常见初始化代码的示例。
There’s one exception to this rule of reusing test fixtures. You can instantiate a fixture in the constructor if it’s used by all or almost all tests. This is often the case for integration tests that work with a database. All such tests require a database connection, which you can initialize once and then reuse everywhere. But even then, it would make more sense to introduce a base class and initialize the database connection in that class’s constructor, not in individual test classes. See the following listing for an example of common initialization code in a base class.
公共类客户测试:集成测试
{
[事实]
public void Purchase_succeeds_when_enough_inventory()
{
/* 在这里使用_database */
}
}
公共抽象类 IntegrationTests:IDisposable
{
受保护的只读数据库_database;
受保护的 IntegrationTests()
{
_database = 新数据库();
}
公共无效处置()
{
_数据库.Dispose();
}
}public class CustomerTests : IntegrationTests
{
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
/* use _database here */
}
}
public abstract class IntegrationTests : IDisposable
{
protected readonly Database _database;
protected IntegrationTests()
{
_database = new Database();
}
public void Dispose()
{
_database.Dispose();
}
}
注意它如何保持无构造函数。它通过从基类继承来CustomerTests访问实例。_databaseIntegrationTests
Notice how CustomerTests remains constructor-less. It gets access to the _database instance by inheriting from the IntegrationTests base class.
为测试赋予富有表现力的名称非常重要。正确的命名有助于您了解测试验证的内容以及底层系统的行为方式。
It’s important to give expressive names to your tests. Proper naming helps you understand what the test verifies and how the underlying system behaves.
那么,应该如何命名单元测试呢?过去十年来,我见过并尝试过很多命名惯例。其中最突出、但可能最没用的惯例是以下惯例:
So, how should you name a unit test? I’ve seen and tried a lot of naming conventions over the past decade. One of the most prominent, and probably least helpful, is the following convention:
[测试方法]_[场景]_[预期结果]
[MethodUnderTest]_[Scenario]_[ExpectedResult]
在哪里
where
它没有什么帮助,因为它鼓励你关注实现细节而不是行为。
It’s unhelpful specifically because it encourages you to focus on implementation details instead of the behavior.
用通俗易懂的英语写出的简单短语效果更好:它们更具表现力,不会将您限制在严格的命名结构中。使用简单的短语,您可以以对客户或领域专家有意义的方式描述系统行为。为了给您举一个用通俗易懂的英语命名的测试示例,下面再次给出清单 3.5中的测试:
Simple phrases in plain English do a much better job: they are more expressive and don’t box you in a rigid naming structure. With simple phrases, you can describe the system behavior in a way that’s meaningful to a customer or a domain expert. To give you an example of a test titled in plain English, here’s the test from listing 3.5 once again:
公共类计算器测试
{
[事实]
公共无效两个数字之和()
{
双倍第一=10;
双秒=20;
var sut = new 计算器();
双精度结果 = sut.Sum(第一,第二);
断言.等于(30,结果);
}
}public class CalculatorTests
{
[Fact]
public void Sum_of_two_numbers()
{
double first = 10;
double second = 20;
var sut = new Calculator();
double result = sut.Sum(first, second);
Assert.Equal(30, result);
}
}
Sum_of_two_numbers如何使用约定重写测试的名称( ) [MethodUnderTest]_[Scenario]_[ExpectedResult]?可能是这样的:
How could the test’s name (Sum_of_two_numbers) be rewritten using the [MethodUnderTest]_[Scenario]_[ExpectedResult] convention? Probably something like this:
公共无效Sum_TwoNumbers_ReturnsSum()
public void Sum_TwoNumbers_ReturnsSum()
被测方法是Sum,场景包括两个数字,预期结果是这两个数字的总和。新名称在程序员眼中看起来很合乎逻辑,但它真的有助于提高测试的可读性吗?一点也不。对于不了解情况的人来说,这简直是天方夜谭。想想看:为什么 会Sum在测试名称中出现两次?这种Returns措辞到底是什么意思?总和返回到哪里?你不可能知道。
The method under test is Sum, the scenario includes two numbers, and the expected result is a sum of those two numbers. The new name looks logical to a programmer’s eye, but does it really help with test readability? Not at all. It’s Greek to an uninformed person. Think about it: Why does Sum appear twice in the name of the test? And what is this Returns phrasing all about? Where is the sum returned to? You can’t know.
有些人可能会说,非程序员对这个名字的看法并不重要。毕竟,单元测试是由程序员为程序员编写的,而不是为领域专家编写的。程序员擅长解读神秘的名字——这是他们的工作!
Some might argue that it doesn’t really matter what a non-programmer would think of this name. After all, unit tests are written by programmers for programmers, not domain experts. And programmers are good at deciphering cryptic names—it’s their job!
确实如此,但只是在一定程度上。神秘的名称对每个人来说都是认知负担,无论是否是程序员。他们需要额外的大脑容量来弄清楚测试究竟验证了什么以及它与业务需求的关系。这似乎并不多,但随着时间的推移,精神负担会逐渐增加。它会缓慢但肯定地增加整个测试套件的维护成本。如果您在忘记了功能的细节后返回测试,或者尝试理解同事编写的测试,这一点尤其明显。阅读别人的代码已经够难了——任何帮助理解它的方法都是非常有用的。
This is true, but only to a degree. Cryptic names impose a cognitive tax on everyone, programmers or not. They require additional brain capacity to figure out what exactly the test verifies and how it relates to business requirements. This may not seem like much, but the mental burden adds up over time. It slowly but surely increases the maintenance cost for the entire test suite. It’s especially noticeable if you return to the test after you’ve forgotten about the feature’s specifics, or try to understand a test written by a colleague. Reading someone else’s code is already difficult enough—any help understanding it is of considerable use.
以下是两个版本:
Here are the two versions again:
公共无效两个数字之和() 公共无效Sum_TwoNumbers_ReturnsSum()
public void Sum_of_two_numbers() public void Sum_TwoNumbers_ReturnsSum()
用通俗易懂的英语书写的初始名称读起来更加简单。它是对测试行为的直观描述。
The initial name written in plain English is much simpler to read. It is a down-to-earth description of the behavior under test.
遵循以下准则来编写富有表现力、易于阅读的测试名称:
Adhere to the following guidelines to write expressive, easily readable test names:
请注意,我在命名测试类时没有使用下划线CalculatorTests。通常,类的名称不会太长,因此没有下划线也读起来很好。
Notice that I didn’t use underscores when naming the test class, CalculatorTests. Normally, the names of classes are not as long, so they read fine without underscores.
还要注意,虽然我在命名测试类时使用了模式[ClassName]Tests,但这并不意味着测试仅限于验证该类。请记住,单元测试中的单元是行为单元,而不是类。这个单元可以跨越一个或多个类;实际大小无关紧要。不过,你还是得从某个地方开始。将 中的类视为[ClassName]Tests:一个入口点,一个 API,你可以使用它来验证行为单元。
Also notice that although I use the pattern [ClassName]Tests when naming test classes, it doesn’t mean the tests are limited to verifying only that class. Remember, the unit in unit testing is a unit of behavior, not a class. This unit can span across one or several classes; the actual size is irrelevant. Still, you have to start somewhere. View the class in [ClassName]Tests as just that: an entry point, an API, using which you can verify a unit of behavior.
让我们以一个测试为例,尝试使用我刚刚概述的指导原则逐步改进其名称。在下面的清单中,您可以看到一个测试,用于验证具有过去日期的交付是否无效。测试的名称使用严格的命名策略编写,这对测试的可读性没有帮助。
Let’s take a test as an example and try to gradually improve its name using the guidelines I just outlined. In the following listing, you can see a test verifying that a delivery with a past date is invalid. The test’s name is written using the rigid naming policy that doesn’t help with the test readability.
[事实]
公共无效IsDeliveryValid_InvalidDate_ReturnsFalse()
{
送货服务 sut = 新的送货服务 ();
DateTime pastDate = DateTime.Now.AddDays(-1);
送货送货=新送货
{
日期 = pastDate
};
bool isValid = sut.IsDeliveryValid(交付);
断言.False(是有效的);
}[Fact]
public void IsDeliveryValid_InvalidDate_ReturnsFalse()
{
DeliveryService sut = new DeliveryService();
DateTime pastDate = DateTime.Now.AddDays(-1);
Delivery delivery = new Delivery
{
Date = pastDate
};
bool isValid = sut.IsDeliveryValid(delivery);
Assert.False(isValid);
}
此测试检查是否DeliveryService正确将日期错误的交货标识为无效。您将如何用简单的英语重写测试名称?以下将是一个很好的首次尝试:
This test checks that DeliveryService properly identifies a delivery with an incorrect date as invalid. How would you rewrite the test’s name in plain English? The following would be a good first try:
public void Delivery_with_invalid_date_should_be_considered_invalid()
public void Delivery_with_invalid_date_should_be_considered_invalid()
请注意新版本中的两件事:
Notice two things in the new version:
第二点是用通俗易懂的英语重写测试名称的自然结果,因此很容易被忽视。然而,这个结果很重要,可以提升为一条指导方针。
The second point is a natural consequence of rewriting the test’s name in plain English and thus can be easily overlooked. However, this consequence is important and can be elevated into a guideline of its own.
不要在测试名称中包含 SUT 方法的名称。
Don’t include the name of the SUT’s method in the test’s name.
请记住,您测试的不是代码,而是应用程序行为。因此,被测方法的名称并不重要。如前所述,SUT 只是一个入口点:一种调用行为的方法。您可以决定将被测方法重命名为,IsDeliveryCorrect这不会对 SUT 的行为产生任何影响。另一方面,如果您遵循原始命名约定,则必须重命名测试。这再次表明,以代码而不是行为为目标会将测试与该代码的实现细节耦合在一起,从而对测试套件的可维护性产生负面影响。有关此问题的更多信息,请参见第 5 章。
Remember, you don’t test code, you test application behavior. Therefore, it doesn’t matter what the name of the method under test is. As I mentioned previously, the SUT is just an entry point: a means to invoke a behavior. You can decide to rename the method under test to, say, IsDeliveryCorrect, and it will have no effect on the SUT’s behavior. On the other hand, if you follow the original naming convention, you’ll have to rename the test. This once again shows that targeting code instead of behavior couples tests to that code’s implementation details, which negatively affects the test suite’s maintainability. More on this issue in chapter 5.
此准则的唯一例外是当您处理实用程序代码时。此类代码不包含业务逻辑 — 其行为不会超出简单的辅助功能,因此对业务人员没有任何意义。在那里使用 SUT 的方法名称是可以的。
The only exception to this guideline is when you work on utility code. Such code doesn’t contain business logic—its behavior doesn’t go much beyond simple auxiliary functionality and thus doesn’t mean anything to business people. It’s fine to use the SUT’s method names there.
但是让我们回到示例。测试名称的新版本是一个好的开始,但它可以进一步改进。交货日期无效究竟意味着什么?从清单 3.10中的测试中,我们可以看到无效日期是过去的任何日期。这是有道理的——您只应该被允许选择未来的交货日期。
But let’s get back to the example. The new version of the test’s name is a good start, but it can be improved further. What does it mean for a delivery date to be invalid, exactly? From the test in listing 3.10, we can see that an invalid date is any date in the past. This makes sense—you should only be allowed to choose a delivery date in the future.
因此让我们具体一点,并在测试名称中反映这些知识:
So let’s be specific and reflect this knowledge in the test’s name:
public void Delivery_with_past_date_should_be_considered_invalid()
public void Delivery_with_past_date_should_be_considered_invalid()
这样虽然好一些,但仍然不理想。它太冗长了。我们可以去掉这个词considered而不会丢失任何含义:
This is better but still not ideal. It’s too verbose. We can get rid of the word considered without any loss of meaning:
public void Delivery_with_past_date_should_be_invalid()
public void Delivery_with_past_date_should_be_invalid()
措辞应该是另一种常见的反模式。在本章前面,我提到测试是关于行为单元的单一原子事实。陈述事实时,没有地方表达愿望或欲望。相应地命名测试 - 将应该是替换为是:
The wording should be is another common anti-pattern. Earlier in this chapter, I mentioned that a test is a single, atomic fact about a unit of behavior. There’s no place for a wish or a desire when stating a fact. Name the test accordingly—replace should be with is:
public void Delivery_with_past_date_is_invalid()
public void Delivery_with_past_date_is_invalid()
最后,没有必要回避基本的英语语法。冠词有助于测试阅读无误。在测试名称中添加冠词a :
And finally, there’s no need to avoid basic English grammar. Articles help the test read flawlessly. Add the article a to the test’s name:
public void Delivery_with_a_past_date_is_invalid()
public void Delivery_with_a_past_date_is_invalid()
就是这样。这个最终版本是对事实的直截了当的陈述,它本身描述了被测试应用程序行为的一个方面:在这个特定情况下,是确定是否可以进行交付的方面。
There you go. This final version is a straight-to-the-point statement of a fact, which itself describes one of the aspects of the application behavior under test: in this particular case, the aspect of determining whether a delivery can be done.
一个测试通常不足以完全描述一个行为单元。这样的单元通常由多个组件组成,每个组件都应该用自己的测试来捕获。如果行为足够复杂,描述它的测试数量可能会急剧增加,甚至变得难以管理。幸运的是,大多数单元测试框架都提供了使用参数化测试对类似测试进行分组的功能(见图3.2)。在本节中,我将首先展示由单独测试描述的每个此类行为组件,然后演示如何将这些测试分组在一起。
One test usually is not enough to fully describe a unit of behavior. Such a unit normally consists of multiple components, each of which should be captured with its own test. If the behavior is complex enough, the number of tests describing it can grow dramatically and may become unmanageable. Luckily, most unit testing frameworks provide functionality that allows you to group similar tests using parameterized tests (see figure 3.2). In this section, I’ll first show each such behavior component described by a separate test and then demonstrate how these tests can be grouped together.
假设我们的送货功能的工作方式是,最早允许的送货日期是两天后。显然,我们仅有的一项测试是不够的。除了检查过去送货日期的测试外,我们还需要检查今天的日期、明天的日期以及之后的日期的测试。
Let’s say that our delivery functionality works in such a way that the soonest allowed delivery date is two days from now. Clearly, the one test we have isn’t enough. In addition to the test that checks for a past delivery date, we’ll also need tests that check for today’s date, tomorrow’s date, and the date after that.
现有的测试名为Delivery_with_a_past_date_is_invalid。我们可以再添加三个:
The existing test is called Delivery_with_a_past_date_is_invalid. We could add three more:
公共无效Delivery_for_today_is_invalid() public void Delivery_for_tomorrow_is_invalid() public void The_soonest_delivery_date_is_two_days_from_now()
public void Delivery_for_today_is_invalid() public void Delivery_for_tomorrow_is_invalid() public void The_soonest_delivery_date_is_two_days_from_now()
但这会导致四种测试方法,它们之间的唯一区别是交货日期。
But that would result in four test methods, with the only difference between them being the delivery date.
更好的方法是将这些测试组合成一个,以减少测试代码量。xUnit(与大多数其他测试框架一样)具有一个称为参数化测试的功能,可让您做到这一点。下一个清单显示了这种分组的样子。每个InlineData属性代表有关系统的一个单独事实;它本身就是一个测试用例。
A better approach is to group these tests into one in order to reduce the amount of test code. xUnit (like most other test frameworks) has a feature called parameterized tests that allows you to do exactly that. The next listing shows how such grouping looks. Each InlineData attribute represents a separate fact about the system; it’s a test case in its own right.
公共类DeliveryServiceTests
{
[InlineData(-1,false)] 1
[InlineData(0,false)] 1
[InlineData(1,false)] 1
[InlineData(2,true)] 1
[理论]
public void Can_detect_an_invalid_delivery_date(
int daysFromNow, 2
bool 预期) 2
{
送货服务 sut = 新的送货服务 ();
日期时间 deliveryDate = 日期时间.现在
.添加天数(从现在开始计算天数); 3
送货送货=新送货
{
日期 = deliveryDate
};
bool isValid = sut.IsDeliveryValid(交付);
断言.Equal(预期,isValid); 3
}
}public class DeliveryServiceTests
{
[InlineData(-1, false)] 1
[InlineData(0, false)] 1
[InlineData(1, false)] 1
[InlineData(2, true)] 1
[Theory]
public void Can_detect_an_invalid_delivery_date(
int daysFromNow, 2
bool expected) 2
{
DeliveryService sut = new DeliveryService();
DateTime deliveryDate = DateTime.Now
.AddDays(daysFromNow); 3
Delivery delivery = new Delivery
{
Date = deliveryDate
};
bool isValid = sut.IsDeliveryValid(delivery);
Assert.Equal(expected, isValid); 3
}
}
注意使用[Theory]属性而不是[Fact]。理论是有关行为的一堆事实。
Notice the use of the [Theory] attribute instead of [Fact]. A theory is a bunch of facts about the behavior.
现在,每个事实都用[InlineData]一行来表示,而不是单独的测试。我还将测试方法重命名为更通用的名称:它不再提及有效或无效日期的构成。
Each fact is now represented by an [InlineData] line rather than a separate test. I also renamed the test method something more generic: it no longer mentions what constitutes a valid or invalid date.
使用参数化测试,您可以显著减少测试代码量,但这种好处是有代价的。现在很难弄清楚测试方法代表了什么事实。参数越多,难度就越大。作为一种折衷,您可以将正面测试用例提取到其自己的测试中,并在最重要的地方受益于描述性命名——确定有效和无效交货日期的区别,如下面的清单所示。
Using parameterized tests, you can significantly reduce the amount of test code, but this benefit comes at a cost. It’s now hard to figure out what facts the test method represents. And the more parameters there are, the harder it becomes. As a compromise, you can extract the positive test case into its own test and benefit from the descriptive naming where it matters the most—in determining what differentiates valid and invalid delivery dates, as shown in the following listing.
公共类DeliveryServiceTests
{
[内联数据(-1)]
[内联数据(0)]
[内联数据(1)]
[理论]
public void Detects_an_invalid_delivery_date(int daysFromNow)
{
/* ... */
}
[事实]
public void The_soonest_delivery_date_is_two_days_from_now()
{
/* ... */
}
}public class DeliveryServiceTests
{
[InlineData(-1)]
[InlineData(0)]
[InlineData(1)]
[Theory]
public void Detects_an_invalid_delivery_date(int daysFromNow)
{
/* ... */
}
[Fact]
public void The_soonest_delivery_date_is_two_days_from_now()
{
/* ... */
}
}
这种方法还简化了负面测试用例,因为您可以expected从测试方法中删除布尔参数。当然,您也可以将正面测试方法转换为参数化测试,以测试多个日期。
This approach also simplifies the negative test cases, since you can remove the expected Boolean parameter from the test method. And, of course, you can transform the positive test method into a parameterized test as well, to test multiple dates.
如您所见,测试代码的数量和代码的可读性之间存在权衡。根据经验法则,只有当从输入参数中可以明显看出哪个案例代表什么时,才将正面和负面测试用例放在一个方法中。否则,提取正面测试用例。如果行为太复杂,则根本不要使用参数化测试。用自己的测试方法来表示每个负面和正面测试用例。
As you can see, there’s a trade-off between the amount of test code and the readability of that code. As a rule of thumb, keep both positive and negative test cases together in a single method only when it’s self-evident from the input parameters which case stands for what. Otherwise, extract the positive test cases. And if the behavior is too complicated, don’t use the parameterized tests at all. Represent each negative and positive test case with its own test method.
使用参数化测试(至少在 .NET 中)时,您需要注意一些注意事项。请注意,在清单 3.11中,我使用daysFromNow参数作为测试方法的输入。您可能会问,为什么不使用实际的日期和时间?不幸的是,以下代码不起作用:
There are some caveats in using parameterized tests (at least, in .NET) that you need to be aware of. Notice that in listing 3.11, I used the daysFromNow parameter as an input to the test method. Why not the actual date and time, you might ask? Unfortunately, the following code won’t work:
[InlineData( DateTime.Now.AddDays(-1) , false)]
[InlineData( DateTime.Now, false )]
[InlineData( DateTime.Now.AddDays(1) , false)]
[InlineData( DateTime.Now.AddDays(2) , true)]
[理论]
public void Can_detect_an_invalid_delivery_date(
日期时间交货日期,
bool 预期)
{
送货服务 sut = 新的送货服务 ();
送货送货=新送货
{
日期 = deliveryDate
};
bool isValid = sut.IsDeliveryValid(交付);
断言.Equal(预期,isValid);
}[InlineData(DateTime.Now.AddDays(-1), false)]
[InlineData(DateTime.Now, false)]
[InlineData(DateTime.Now.AddDays(1), false)]
[InlineData(DateTime.Now.AddDays(2), true)]
[Theory]
public void Can_detect_an_invalid_delivery_date(
DateTime deliveryDate,
bool expected)
{
DeliveryService sut = new DeliveryService();
Delivery delivery = new Delivery
{
Date = deliveryDate
};
bool isValid = sut.IsDeliveryValid(delivery);
Assert.Equal(expected, isValid);
}
在 C# 中,所有属性的内容都在编译时进行评估。您必须仅使用编译器可以理解的值,如下所示:
In C#, the content of all attributes is evaluated at compile time. You have to use only those values that the compiler can understand, which are as follows:
调用DateTime.Now依赖于 .NET 运行时,因此是不允许的。
The call to DateTime.Now relies on the .NET runtime and thus is not allowed.
有一种方法可以解决这个问题。xUnit 还有另一个功能,可用于生成自定义数据以输入到测试方法中:[MemberData]。下一个清单显示了如何使用此功能重写以前的测试。
There is a way to overcome this problem. xUnit has another feature that you can use to generate custom data to feed into the test method: [MemberData]. The next listing shows how we can rewrite the previous test using this feature.
[理论]
[成员数据(名称(数据))]
public void Can_detect_an_invalid_delivery_date(
日期时间交货日期,
bool 预期)
{
/* ... */
}
公共静态列表<object[]> 数据()
{
返回新的 List<object[]>
{
新对象[] { DateTime.Now.AddDays(-1), false },
新对象[] { DateTime.Now, false },
新对象[] { DateTime.Now.AddDays(1), false },
新对象[] { DateTime.Now.AddDays(2), true }
};
}[Theory]
[MemberData(nameof(Data))]
public void Can_detect_an_invalid_delivery_date(
DateTime deliveryDate,
bool expected)
{
/* ... */
}
public static List<object[]> Data()
{
return new List<object[]>
{
new object[] { DateTime.Now.AddDays(-1), false },
new object[] { DateTime.Now, false },
new object[] { DateTime.Now.AddDays(1), false },
new object[] { DateTime.Now.AddDays(2), true }
};
}
MemberData接受静态方法的名称,该方法生成输入数据集合(编译器将其转换nameof(Data)为"Data"文字)。集合中的每个元素本身都是一个集合,该集合映射到两个输入参数:deliveryDate和expected。使用此功能,您可以克服编译器的限制并在参数化测试中使用任何类型的参数。
MemberData accepts the name of a static method that generates a collection of input data (the compiler translates nameof(Data) into a "Data" literal). Each element of the collection is itself a collection that is mapped into the two input parameters: deliveryDate and expected. With this feature, you can overcome the compiler’s restrictions and use parameters of any type in the parameterized tests.
为提高测试可读性,您还可以使用断言库。我个人更喜欢 Fluent Assertions ( https://fluentassertions.com ),但 .NET 在这方面有几个竞争库。
One more thing you can do to improve test readability is to use an assertion library. I personally prefer Fluent Assertions (https://fluentassertions.com), but .NET has several competing libraries in this area.
使用断言库的主要好处是你可以重构断言,使其更具可读性。这是我们之前的一个测试:
The main benefit of using an assertion library is how you can restructure the assertions so that they are more readable. Here’s one of our earlier tests:
[事实]
公共无效两个数字之和()
{
var sut = new 计算器();
双精度结果 = sut.Sum(10, 20);
断言.等于(30,结果);
}[Fact]
public void Sum_of_two_numbers()
{
var sut = new Calculator();
double result = sut.Sum(10, 20);
Assert.Equal(30, result);
}
现在将其与以下使用流畅断言的内容进行比较:
Now compare it to the following, which uses a fluent assertion:
[事实]
公共无效两个数字之和()
{
var sut = new 计算器();
双精度结果 = sut.Sum(10, 20);
结果.应该()。是(30);
}[Fact]
public void Sum_of_two_numbers()
{
var sut = new Calculator();
double result = sut.Sum(10, 20);
result.Should().Be(30);
}
第二个测试中的断言读起来就像普通英语,这正是您希望所有代码的阅读方式。作为人类,我们更喜欢以故事的形式吸收信息。所有故事都遵循这一特定模式:
The assertion from the second test reads as plain English, which is exactly how you want all your code to read. We as humans prefer to absorb information in the form of stories. All stories adhere to this specific pattern:
[主语] [动作] [宾语]。
[Subject] [action] [object].
例如,
For example,
鲍勃打开了门。
Bob opened the door.
这里,Bob是主语,opened是动作,the door是宾语。同样的规则也适用于代码。result.Should().Be(30)读起来比 更好,Assert.Equal(30, result)因为它遵循故事模式。这是一个简单的故事,其中result是主语,should be是动作,30是宾语。
Here, Bob is a subject, opened is an action, and the door is an object. The same rule applies to code. result.Should().Be(30) reads better than Assert.Equal(30, result) precisely because it follows the story pattern. It’s a simple story in which result is a subject, should be is an action, and 30 is an object.
面向对象编程 (OOP) 范式之所以成功,部分原因就在于其可读性优势。借助 OOP,您也可以以一种像故事一样易读的方式来构建代码。
The paradigm of object-oriented programming (OOP) has become a success partly because of this readability benefit. With OOP, you, too, can structure the code in a way that reads like a story.
Fluent Assertions 库还提供了许多辅助方法来针对数字、字符串、集合、日期和时间等进行断言。唯一的缺点是,这样的库是一个额外的依赖项,你可能不想将其引入到你的项目中(尽管它仅用于开发,不会投入生产)。
The Fluent Assertions library also provides numerous helper methods to assert against numbers, strings, collections, dates and times, and much more. The only drawback is that such a library is an additional dependency you may not want to introduce to your project (although it’s for development only and won’t be shipped to production).
现在您已经掌握了单元测试的用途,您可以开始深入研究如何进行良好测试,并学习如何重构测试以提高价值。在第 4 章中,您将了解构成良好单元测试的四个支柱。这四个支柱奠定了基础,即一个共同的参考框架,我们将使用它来分析未来的单元测试和测试方法。
Now that you’re armed with the knowledge of what unit testing is for, you’re ready to dive into the very crux of what makes a good test and learn how to refactor your tests toward being more valuable. In chapter 4, you’ll learn about the four pillars that make up a good unit test. These four pillars set a foundation, a common frame of reference, which we’ll use to analyze unit tests and testing approaches moving forward.
第 5 章采用第 4 章中建立的参考框架,并构建了模拟的案例及其与测试脆弱性的关系。
Chapter 5 takes the frame of reference established in chapter 4 and builds the case for mocks and their relation to test fragility.
第 6 章使用相同的参考框架来研究三种单元测试风格。它展示了哪种风格往往能产生质量最好的测试,以及原因。
Chapter 6 uses the same the frame of reference to examine the three styles of unit testing. It shows which of those styles tends to produce tests of the best quality, and why.
第 7 章将第 4 章至第 6 章的知识付诸实践,并教您如何重构臃肿、过于复杂的测试,以提供尽可能多价值且维护成本尽可能少的测试。
Chapter 7 puts the knowledge from chapters 4 to 6 into practice and teaches you how to refactor away from bloated, overcomplicated tests to tests that provide as much value with as little maintenance cost as possible.
本章涵盖
This chapter covers
现在我们来谈谈问题的核心。在第 1 章中,您了解了优秀单元测试套件的属性:
Now we are getting to the heart of the matter. In chapter 1, you saw the properties of a good unit test suite:
正如我们在第 1 章中讨论的那样,识别有价值的测试和编写有价值的测试是两种不同的技能。不过,后一种技能需要前一种技能;因此,在本章中,我将展示如何识别有价值的测试。您将看到一个通用的参考框架,您可以使用它来分析套件中的任何测试。然后,我们将使用此参考框架来介绍一些流行的单元测试概念:测试金字塔和黑盒测试与白盒测试。
As we discussed in chapter 1, recognizing a valuable test and writing a valuable test are two separate skills. The latter skill requires the former one, though; so, in this chapter, I’ll show how to recognize a valuable test. You’ll see a universal frame of reference with which you can analyze any test in the suite. We’ll then use this frame of reference to go over some popular unit testing concepts: the Test Pyramid and black-box versus white-box testing.
系好安全带:我们出发了。
Buckle up: we are starting out.
好的单元测试具有以下四个属性:
A good unit test has the following four attributes:
这四个属性是基础。您可以使用它们来分析任何自动化测试,无论是单元测试、集成测试还是端到端测试。每个这样的测试都在某种程度上表现出每个属性。在本节中,我定义了前两个属性;在第 4.2 节中,我描述了它们之间的内在联系。
These four attributes are foundational. You can use them to analyze any automated test, be it unit, integration, or end-to-end. Every such test exhibits some degree of each attribute. In this section, I define the first two attributes; and in section 4.2, I describe the intrinsic connection between them.
让我们从良好单元测试的第一个属性开始:防止回归。正如您从第 1 章中所知,回归是一种软件错误。它是指在修改一些代码后,功能不再按预期运行,通常是在您推出新功能之后。
Let’s start with the first attribute of a good unit test: protection against regressions. As you know from chapter 1, a regression is a software bug. It’s when a feature stops working as intended after some code modification, usually after you roll out new functionality.
这样的回归很烦人(至少可以这么说),但这还不是最糟糕的部分。最糟糕的是,你开发的功能越多,在新版本中破坏其中一个功能的可能性就越大。编程生活中一个不幸的事实是,代码不是一项资产,而是一项负债。代码库越大,就越有可能出现潜在的错误。这就是为什么开发良好的回归保护措施至关重要。如果没有这样的保护,你将无法长期维持项目增长——你会被越来越多的错误所淹没。
Such regressions are annoying (to say the least), but that’s not the worst part about them. The worst part is that the more features you develop, the more chances there are that you’ll break one of those features with a new release. An unfortunate fact of programming life is that code is not an asset, it’s a liability. The larger the code base, the more exposure it has to potential bugs. That’s why it’s crucial to develop a good protection against regressions. Without such protection, you won’t be able to sustain the project growth in a long run—you’ll be buried under an ever-increasing number of bugs.
要评估测试在防止回归方面的得分情况,您需要考虑以下几点:
To evaluate how well a test scores on the metric of protecting against regressions, you need to take into account the following:
一般来说,执行的代码量越大,测试揭示回归的可能性就越大。当然,假设这个测试有一组相关的断言,你不会想仅仅执行代码。虽然知道这段代码运行时没有抛出异常是有帮助的,但你还需要验证它产生的结果。
Generally, the larger the amount of code that gets executed, the higher the chance that the test will reveal a regression. Of course, assuming that this test has a relevant set of assertions, you don’t want to merely execute the code. While it helps to know that this code runs without throwing exceptions, you also need to validate the outcome it produces.
请注意,重要的不仅是代码量,还有其复杂性和领域重要性。代表复杂业务逻辑的代码比样板代码更重要——业务关键功能中的错误最具破坏性。
Note that it’s not only the amount of code that matters, but also its complexity and domain significance. Code that represents complex business logic is more important than boilerplate code—bugs in business-critical functionality are the most damaging.
另一方面,测试琐碎代码几乎不值得。这样的代码很短,不包含大量业务逻辑。覆盖琐碎代码的测试不太可能发现回归错误,因为出错的空间不大。琐碎代码的一个例子是这样的单行属性:
On the other hand, it’s rarely worthwhile to test trivial code. Such code is short and doesn’t contain a substantial amount of business logic. Tests that cover trivial code don’t have much of a chance of finding a regression error, because there’s not a lot of room for a mistake. An example of trivial code is a single-line property like this:
公共类用户
{
公共字符串名称 { 获取; 设置; }
}public class User
{
public string Name { get; set; }
}
此外,除了您的代码之外,您未编写的代码也算在内:例如,库、框架和项目中使用的任何外部系统。这些代码对软件运行的影响几乎与您自己的代码一样大。为了获得最佳保护,测试必须将这些库、框架和外部系统纳入测试范围,以检查您的软件对这些依赖项所做的假设是否正确。
Furthermore, in addition to your code, the code you didn’t write also counts: for example, libraries, frameworks, and any external systems used in the project. That code influences the working of your software almost as much as your own code. For the best protection, the test must include those libraries, frameworks, and external systems in the testing scope, in order to check that the assumptions your software makes about these dependencies are correct.
为了最大限度地提高对回归的保护程度,测试需要尽可能多地执行代码。
To maximize the metric of protection against regressions, the test needs to aim at exercising as much code as possible.
良好单元测试的第二个属性是抗重构性——测试能够维持底层应用程序代码重构而不会变红(失败)的程度。
The second attribute of a good unit test is resistance to refactoring—the degree to which a test can sustain a refactoring of the underlying application code without turning red (failing).
重构意味着更改现有代码而不修改其可观察的行为。目的通常是为了改善代码的非功能性特征:提高可读性并降低复杂性。重构的一些示例包括重命名方法和将一段代码提取到新类中。
Refactoring means changing existing code without modifying its observable behavior. The intention is usually to improve the code’s nonfunctional characteristics: increase readability and reduce complexity. Some examples of refactoring are renaming a method and extracting a piece of code into a new class.
想象一下这种情况。你开发了一个新功能,一切都运行良好。该功能本身正在发挥作用,所有测试都通过了。现在你决定清理代码。你在这里做一些重构,在那里做一些修改,一切看起来都比以前更好。除了一件事——测试失败了。你仔细查看重构到底破坏了什么,但结果发现你没有破坏任何东西。该功能运行良好,和以前一样。问题是测试的编写方式使得它们在修改底层代码时变成红色。不管你是否真的破坏了功能本身,它们都会这样。
Picture this situation. You developed a new feature, and everything works great. The feature itself is doing its job, and all the tests are passing. Now you decide to clean up the code. You do some refactoring here, a little bit of modification there, and everything looks even better than before. Except one thing—the tests are failing. You look more closely to see exactly what you broke with the refactoring, but it turns out that you didn’t break anything. The feature works perfectly, just as before. The problem is that the tests are written in such a way that they turn red with any modification of the underlying code. And they do that regardless of whether you actually break the functionality itself.
这种情况称为假阳性。 假阳性是一种虚惊。 这是一种表明测试失败的结果,但实际上它所涵盖的功能按预期工作。 这种假阳性通常发生在您重构代码时 - 当您修改实现但保持可观察行为不变时。 因此,好的单元测试的这一属性被称为:抗重构。
This situation is called a false positive. A false positive is a false alarm. It’s a result indicating that the test fails, although in reality, the functionality it covers works as intended. Such false positives usually take place when you refactor the code—when you modify the implementation but keep the observable behavior intact. Hence the name for this attribute of a good unit test: resistance to refactoring.
要评估测试在抵抗重构方面的得分,您需要查看测试产生了多少误报。 越少越好。
To evaluate how well a test scores on the metric of resisting to refactoring, you need to look at how many false positives the test generates. The fewer, the better.
为什么要如此关注误报?因为它们可能会对整个测试套件产生毁灭性的影响。您可能还记得第 1 章的内容,单元测试的目标是实现可持续的项目增长。测试实现可持续增长的机制是,它们允许您添加新功能并进行定期重构而不会引入回归。这里有两个具体的好处:
Why so much attention on false positives? Because they can have a devastating effect on your entire test suite. As you may recall from chapter 1, the goal of unit testing is to enable sustainable project growth. The mechanism by which the tests enable sustainable growth is that they allow you to add new features and conduct regular refactorings without introducing regressions. There are two specific benefits here:
假阳性会干扰这两种益处:
False positives interfere with both of these benefits:
我曾经参与过一个历史悠久的项目。这个项目并不算太老,大概有两三年了;但在此期间,管理层大大改变了项目的发展方向,开发方向也随之改变。在这次变革中,出现了一个问题:代码库积累了大量的剩余代码,没有人敢删除或重构这些代码。公司不再需要这些代码提供的功能,但其中的一些部分却被用于新功能,因此不可能完全摆脱旧代码。
I once worked on a project with a rich history. The project wasn’t too old, maybe two or three years; but during that period of time, management significantly shifted the direction they wanted to go with the project, and development changed direction accordingly. During this change, a problem emerged: the code base accumulated large chunks of leftover code that no one dared to delete or refactor. The company no longer needed the features that code provided, but some parts of it were used in new functionality, so it was impossible to get rid of the old code completely.
该项目的测试覆盖率很高。但每次有人试图重构旧功能并将仍在使用的部分与其他部分分开时,测试就会失败。而且不仅是旧测试(它们很久以前就被禁用了),新测试也是如此。有些失败是合理的,但大多数都不是——它们是误报。
The project had good test coverage. But every time someone tried to refactor the old features and separate the bits that were still in use from everything else, the tests failed. And not just the old tests—they had been disabled long ago—but the new tests, too. Some of the failures were legitimate, but most were not—they were false positives.
起初,开发人员试图处理测试失败。然而,由于绝大多数都是误报,情况发展到开发人员忽略此类失败并禁用失败测试的地步。普遍的态度是,“如果是因为那段旧代码,那就禁用测试吧;我们稍后再查看。”
At first, the developers tried to deal with the test failures. However, since the vast majority of them were false alarms, the situation got to the point where the developers ignored such failures and disabled the failing tests. The prevailing attitude was, “If it’s because of that old chunk of code, just disable the test; we’ll look at it later.”
一段时间内一切都运行良好 — — 直到一个重大错误出现在生产中。其中一个测试正确地识别出了错误,但没有人听从;这个测试和所有其他测试一起被禁用。那次事故之后,开发人员完全停止接触旧代码。
Everything worked fine for a while—until a major bug slipped into production. One of the tests correctly identified the bug, but no one listened; the test was disabled along with all the others. After that accident, the developers stopped touching the old code entirely.
这个故事是大多数测试脆弱性项目的典型案例。首先,开发人员会把测试失败当真,并采取相应的措施。过了一段时间,人们厌倦了测试总是喊“狼来了”,开始越来越忽视它们。最终,会出现一大堆真正的错误被发布到生产环境中,因为开发人员忽略了这些失败和所有的误报。
This story is typical of most projects with brittle tests. First, developers take test failures at face value and deal with them accordingly. After a while, people get tired of tests crying “wolf” all the time and start to ignore them more and more. Eventually, there comes a moment when a bunch of real bugs are released to production because developers ignored the failures along with all the false positives.
不过,你不会想通过停止所有重构来应对这种情况。正确的应对措施是重新评估测试套件并开始降低其脆弱性。我在第 7 章中介绍了这个主题。
You don’t want to react to such a situation by ceasing all refactorings, though. The correct response is to re-evaluate the test suite and start reducing its brittleness. I cover this topic in chapter 7.
那么,什么原因导致误报?如何避免误报?
So, what causes false positives? And how can you avoid them?
测试产生的误报数量与测试的结构方式直接相关。测试与被测系统 (SUT) 的实现细节耦合得越多,产生的误报就越多。减少误报几率的唯一方法是将测试与这些实现细节分离。您需要确保测试验证 SUT 提供的最终结果:其可观察的行为,而不是执行该行为所采取的步骤。测试应从最终用户的角度进行 SUT 验证,并仅检查对该最终用户有意义的结果。其他一切都必须忽略(有关此主题的更多信息,请参阅第 5 章)。
The number of false positives a test produces is directly related to the way the test is structured. The more the test is coupled to the implementation details of the system under test (SUT), the more false alarms it generates. The only way to reduce the chance of getting a false positive is to decouple the test from those implementation details. You need to make sure the test verifies the end result the SUT delivers: its observable behavior, not the steps it takes to do that. Tests should approach SUT verification from the end user’s point of view and check only the outcome meaningful to that end user. Everything else must be disregarded (more on this topic in chapter 5).
构建测试的最佳方式是让它讲述一个关于问题领域的故事。如果这样的测试失败,那么失败将意味着故事与实际应用程序行为之间存在脱节。这是唯一一种对你有益的测试失败——这种失败总是切中要害,并能帮助你快速了解哪里出了问题。所有其他失败都只是噪音,让你的注意力从重要的事情上转移开。
The best way to structure a test is to make it tell a story about the problem domain. Should such a test fail, that failure would mean there’s a disconnect between the story and the actual application behavior. It’s the only type of test failure that benefits you—such failures are always on point and help you quickly understand what went wrong. All other failures are just noise that steer your attention away from things that matter.
请看以下示例。在该示例中,该类MessageRenderer生成包含标题、正文和页脚的消息的 HTML 表示。
Take a look at the following example. In it, the MessageRenderer class generates an HTML representation of a message containing a header, a body, and a footer.
公开课留言
{
公共字符串标头 { 获取; 设置; }
公共字符串主体 { 获取; 设置; }
公共字符串 Footer { 获取; 设置; }
}
公共接口IRenderer
{
字符串渲染(消息消息);
}
公共类 MessageRenderer:IRenderer
{
公共 IReadOnlyList<IRenderer> SubRenderers { 获取; }
公共消息渲染器()
{
SubRenderers = new List<IRenderer>
{
新的 HeaderRenderer(),
新的BodyRenderer(),
新的 FooterRenderer()
};
}
公共字符串渲染(消息消息)
{
返回 SubRenderers
.选择(x => x.渲染(消息))
.聚合(“”,(str1,str2)=> str1 + str2);
}
}public class Message
{
public string Header { get; set; }
public string Body { get; set; }
public string Footer { get; set; }
}
public interface IRenderer
{
string Render(Message message);
}
public class MessageRenderer : IRenderer
{
public IReadOnlyList<IRenderer> SubRenderers { get; }
public MessageRenderer()
{
SubRenderers = new List<IRenderer>
{
new HeaderRenderer(),
new BodyRenderer(),
new FooterRenderer()
};
}
public string Render(Message message)
{
return SubRenderers
.Select(x => x.Render(message))
.Aggregate("", (str1, str2) => str1 + str2);
}
}
该类MessageRenderer包含多个子渲染器,它将消息各部分的实际工作委托给这些子渲染器。然后将结果组合成 HTML 文档。子渲染器使用 HTML 标记来编排原始文本。例如:
The MessageRenderer class contains several sub-renderers to which it delegates the actual work on parts of the message. It then combines the result into an HTML document. The sub-renderers orchestrate the raw text with HTML tags. For example:
公共类 BodyRenderer:IRenderer
{
公共字符串渲染(消息消息)
{
返回 $"<b>{message.Body}";
}
}public class BodyRenderer : IRenderer
{
public string Render(Message message)
{
return $"<b>{message.Body}</b>";
}
}
如何MessageRenderer测试?一种可能的方法是分析该类遵循的算法。
How can MessageRenderer be tested? One possible approach is to analyze the algorithm this class follows.
[事实]
公共无效MessageRenderer_uses_correct_sub_renderers()
{
var sut = new MessageRenderer();
IReadOnlyList<IRenderer> renderers = sut.SubRenderers;
断言.等于(3,渲染器.计数);
声明一个方法,只需要声明一个属性即可。
声明.IsAssignableFrom<BodyRenderer>(渲染器[1]);
声明.IsAssignableFrom<FooterRenderer>(渲染器[2]);
}[Fact]
public void MessageRenderer_uses_correct_sub_renderers()
{
var sut = new MessageRenderer();
IReadOnlyList<IRenderer> renderers = sut.SubRenderers;
Assert.Equal(3, renderers.Count);
Assert.IsAssignableFrom<HeaderRenderer>(renderers[0]);
Assert.IsAssignableFrom<BodyRenderer>(renderers[1]);
Assert.IsAssignableFrom<FooterRenderer>(renderers[2]);
}
此测试检查子渲染器是否都是预期的类型并以正确的顺序出现,这假定MessageRenderer处理消息的方式也必须正确。测试乍一看可能不错,但它真的能验证MessageRenderer可观察到的行为吗?如果您重新排列子渲染器,或用新的子渲染器替换其中一个,会怎么样?这会导致错误吗?
This test checks to see if the sub-renderers are all of the expected types and appear in the correct order, which presumes that the way MessageRenderer processes messages must also be correct. The test might look good at first, but does it really verify MessageRenderer’s observable behavior? What if you rearrange the sub-renderers, or replace one of them with a new one? Will that lead to a bug?
不一定。您可以更改子渲染器的组成,使生成的 HTML 文档保持不变。例如,您可以BodyRenderer用替换BoldRenderer,其功能与 相同BodyRenderer。或者您可以删除所有子渲染器并直接在 中实现渲染MessageRenderer。
Not necessarily. You could change a sub-renderer’s composition in such a way that the resulting HTML document remains the same. For example, you could replace BodyRenderer with a BoldRenderer, which does the same job as BodyRenderer. Or you could get rid of all the sub-renderers and implement the rendering directly in MessageRenderer.
尽管如此,如果你做了任何这些事情,测试都会变成红色,即使最终结果不会改变。这是因为测试与 SUT 的实现细节有关,而不是 SUT 产生的结果。此测试检查算法并期望看到一种特定的实现,而不考虑同样适用的替代实现(见图4.1)。
Still, the test will turn red if you do any of that, even though the end result won’t change. That’s because the test couples to the SUT’s implementation details and not the outcome the SUT produces. This test inspects the algorithm and expects to see one particular implementation, without any consideration for equally applicable alternative implementations (see figure 4.1).
对类进行任何实质性的重构都会导致测试失败。请注意,重构MessageRenderer过程是在不影响应用程序可观察行为的情况下更改实现。正是因为测试与实现细节有关,所以每次更改这些细节时它都会变成红色。因此,与 SUT 的实现细节耦合的测试不能抵抗重构。这样的测试表现出我之前描述的所有缺点:
Any substantial refactoring of the MessageRenderer class would lead to a test failure. Mind you, the process of refactoring is changing the implementation without affecting the application’s observable behavior. And it’s precisely because the test is concerned with the implementation details that it turns red every time you change those details. Therefore, tests that couple to the SUT’s implementation details are not resistant to refactoring. Such tests exhibit all the shortcomings I described previously:
下一个清单显示了我所遇到过的测试中最严重的脆弱性例子,其中测试读取类的源代码MessageRenderer并将其与“正确”的实现进行比较。
The next listing shows the most egregious example of brittleness in tests that I’ve ever encountered, in which the test reads the source code of the MessageRenderer class and compares it to the “correct” implementation.
[事实]
public void MessageRenderer_is_implemented_correctly()
{
字符串源代码 = File.ReadAllText(@“[路径]\MessageRenderer.cs”);
断言.平等(@“
公共类 MessageRenderer:IRenderer
{
公共 IReadOnlyList<<IRenderer> SubRenderers { 获取; }
public MessageRenderer()
{
SubRenderers = new List<<IRenderer>
{
new HeaderRenderer(),
new BodyRenderer(),
new FooterRenderer()
};
}
公共字符串渲染(消息消息){/*...*/}
}“, 源代码);
}[Fact]
public void MessageRenderer_is_implemented_correctly()
{
string sourceCode = File.ReadAllText(@"[path]\MessageRenderer.cs");
Assert.Equal(@"
public class MessageRenderer : IRenderer
{
public IReadOnlyList<<IRenderer> SubRenderers { get; }
public MessageRenderer()
{
SubRenderers = new List<<IRenderer>
{
new HeaderRenderer(),
new BodyRenderer(),
new FooterRenderer()
};
}
public string Render(Message message) { /* ... */ }
}", sourceCode);
}
当然,这个测试简直荒谬至极;只要你修改类中的任何细节,它都会失败MessageRenderer。同时,它与我之前提到的测试并没有什么不同。两者都坚持特定的实现,而没有考虑 SUT 的可观察行为。每次更改该实现时,两者都会变成红色。但不可否认的是,清单 4.3中的测试比清单 4.2中的测试更容易失败。
Of course, this test is just plain ridiculous; it will fail should you modify even the slightest detail in the MessageRenderer class. At the same time, it’s not that different from the test I brought up earlier. Both insist on a particular implementation without taking into consideration the SUT’s observable behavior. And both will turn red each time you change that implementation. Admittedly, though, the test in listing 4.3 will break more often than the one in listing 4.2.
正如我之前提到的,避免测试脆弱性并提高其抗重构性的唯一方法是将它们与 SUT 的实现细节分离——尽可能地让测试与代码的内部工作保持距离,而将目标放在验证最终结果上。让我们这样做:让我们将清单 4.2中的测试重构为更不脆弱的东西。
As I mentioned earlier, the only way to avoid brittleness in tests and increase their resistance to refactoring is to decouple them from the SUT’s implementation details—keep as much distance as possible between the test and the code’s inner workings, and instead aim at verifying the end result. Let’s do that: let’s refactor the test from listing 4.2 into something much less brittle.
首先,您需要问自己以下问题:您从 获得的最终结果是什么MessageRenderer?好吧,它是一条消息的 HTML 表示。这是唯一有意义的检查,因为它是您从类中获得的唯一可观察结果。只要此 HTML 表示保持不变,就无需担心它究竟是如何生成的。这些实现细节无关紧要。以下代码是测试的新版本。
To start off, you need to ask yourself the following question: What is the final outcome you get from MessageRenderer? Well, it’s the HTML representation of a message. And it’s the only thing that makes sense to check, since it’s the only observable result you get out of the class. As long as this HTML representation stays the same, there’s no need to worry about exactly how it’s generated. Such implementation details are irrelevant. The following code is the new version of the test.
[事实]
公共无效Rendering_a_message()
{
var sut = new MessageRenderer();
var message = 新消息
{
标题 = “h”,
主体 = “b”,
页脚 = “f”
};
字符串 html = sut.Render(消息);
Assert.Equal("<h1>h</h1><b>b</b><i>f</i>", html);
}[Fact]
public void Rendering_a_message()
{
var sut = new MessageRenderer();
var message = new Message
{
Header = "h",
Body = "b",
Footer = "f"
};
string html = sut.Render(message);
Assert.Equal("<h1>h</h1><b>b</b><i>f</i>", html);
}
此测试将MessageRenderer其视为黑盒,只对其可观察的行为感兴趣。因此,该测试对重构的抵抗力更强——只要 HTML 输出保持不变,它就不关心您对 SUT 做了什么更改(图 4.2)。
This test treats MessageRenderer as a black box and is only interested in its observable behavior. As a result, the test is much more resistant to refactoring—it doesn’t care what changes you make to the SUT as long as the HTML output remains the same (figure 4.2).
请注意,此测试与原始版本相比有很大改进。它通过验证对最终用户有意义的唯一结果来满足业务需求——消息在浏览器中的显示方式。此类测试的失败总是有针对性的:它们传达了应用程序行为的变化,这可能会影响客户,因此应引起开发人员的注意。这种测试几乎不会产生误报。
Notice the profound improvement in this test over the original version. It aligns itself with the business needs by verifying the only outcome meaningful to end users—how a message is displayed in the browser. Failures of such a test are always on point: they communicate a change in the application behavior that can affect the customer and thus should be brought to the developer’s attention. This test will produce few, if any, false positives.
为什么是很少而不是完全没有?因为仍然可能存在MessageRenderer会破坏测试的更改。例如,您可以在Render()方法中引入一个新参数,从而导致编译错误。从技术上讲,这样的错误也算作误报。毕竟,测试不会因为应用程序行为的变化而失败。
Why few and not none at all? Because there could still be changes in MessageRenderer that would break the test. For example, you could introduce a new parameter in the Render() method, causing a compilation error. And technically, such an error counts as a false positive, too. After all, the test isn’t failing because of a change in the application’s behavior.
但这种误报很容易修复。只需按照编译器的指示,向调用该Render()方法的所有测试添加一个新参数即可。更糟糕的误报是那些不会导致编译错误的误报。这种误报是最难处理的——它们似乎指向了一个合法的错误,需要更多的时间来调查。
But this kind of false positive is easy to fix. Just follow the compiler and add a new parameter to all tests that invoke the Render() method. The worse false positives are those that don’t lead to compilation errors. Such false positives are the hardest to deal with—they seem as though they point to a legitimate bug and require much more time to investigate.
正如我之前提到的,良好单元测试的前两个支柱之间存在内在联系——防止回归和抵制重构。它们都有助于提高测试套件的准确性,尽管角度相反。这两个属性也往往会随着时间的推移对项目产生不同的影响:虽然在项目启动后不久就拥有良好的防止回归的保护措施很重要,但抵制重构的需求并不是迫在眉睫的。
As I mentioned earlier, there’s an intrinsic connection between the first two pillars of a good unit test—protection against regressions and resistance to refactoring. They both contribute to the accuracy of the test suite, though from opposite perspectives. These two attributes also tend to influence the project differently over time: while it’s important to have good protection against regressions very soon after the project’s initiation, the need for resistance to refactoring is not immediate.
在本节中,我将讨论
In this section, I talk about
让我们先回顾一下,从更宏观的角度来看一下测试结果。就代码正确性和测试结果而言,有四种可能的结果,如图 4.3所示。测试可以通过或失败(表格的行)。功能本身可以正确或损坏(表格的列)。
Let’s step back for a second and look at the broader picture with regard to test results. When it comes to code correctness and test results, there are four possible outcomes, as shown in figure 4.3. The test can either pass or fail (the rows of the table). And the functionality itself can be either correct or broken (the table’s columns).
测试通过且底层功能按预期工作的情况是正确推断:测试正确推断了系统的状态(其中没有错误)。这种工作功能和通过测试的组合的另一个术语是真阴性。
The situation when the test passes and the underlying functionality works as intended is a correct inference: the test correctly inferred the state of the system (there are no bugs in it). Another term for this combination of working functionality and a passing test is true negative.
同样,当功能损坏且测试失败时,这也是一个正确的推断。这是因为当功能无法正常工作时,您预计测试会失败。这就是单元测试的全部意义所在。这种情况的对应术语是真阳性。
Similarly, when the functionality is broken and the test fails, it’s also a correct inference. That’s because you expect to see the test fail when the functionality is not working properly. That’s the whole point of unit testing. The corresponding term for this situation is true positive.
但是当测试没有发现错误时,那就是一个问题。这是右上象限,假阴性。这就是良好测试的第一个属性——保护防止回归——帮助您避免。具有良好回归保护的测试可以帮助您最大限度地减少假阴性(II 类错误)的数量。
But when the test doesn’t catch an error, that’s a problem. This is the upper-right quadrant, a false negative. And this is what the first attribute of a good test—protection against regressions—helps you avoid. Tests with a good protection against regressions help you to minimize the number of false negatives—type II errors.
另一方面,如果功能正确,但测试仍然显示失败,则会出现对称情况。这是误报,是误报。这就是第二个属性(抗重构性)可以帮助您解决的问题。
On the other hand, there’s a symmetric situation when the functionality is correct but the test still shows a failure. This is a false positive, a false alarm. And this is what the second attribute—resistance to refactoring—helps you with.
所有这些术语(假阳性、I 型错误等等)都源于统计学,但也可以应用于分析测试套件。理解这些术语的最好方法是想象一下流感测试。如果参加测试的人患了流感,则流感测试结果为阳性。阳性这个术语有点令人困惑,因为患流感并没有什么积极的意义。但测试并不评估整体情况。在测试中,阳性意味着某些条件现在为真。这些是测试创建者设置的条件。在这个特定的例子中,阳性是指流感的存在。相反,没有流感则流感测试结果为阴性。
All these terms (false positive, type I error and so on) have roots in statistics, but can also be applied to analyzing a test suite. The best way to wrap your head around them is to think of a flu test. A flu test is positive when the person taking the test has the flu. The term positive is a bit confusing because there’s nothing positive about having the flu. But the test doesn’t evaluate the situation as a whole. In the context of testing, positive means that some set of conditions is now true. Those are the conditions the creators of the test have set it to react to. In this particular example, it’s the presence of the flu. Conversely, the lack of flu renders the flu test negative.
现在,当你评估流感测试的准确性时,你会提到诸如假阳性或假阴性之类的术语。假阳性和假阴性的概率告诉你流感测试的效果如何:概率越低,测试越准确。
Now, when you evaluate how accurate the flu test is, you bring up terms such as false positive or false negative. The probability of false positives and false negatives tells you how good the flu test is: the lower that probability, the more accurate the test.
准确性是良好单元测试的前两个支柱。防止回归和抵制重构旨在最大限度地提高测试套件的准确性。准确性指标本身由两个部分组成:
This accuracy is what the first two pillars of a good unit test are all about. Protection against regressions and resistance to refactoring aim at maximizing the accuracy of the test suite. The accuracy metric itself consists of two components:
考虑假阳性和假阴性的另一种方法是根据信噪比。从图 4.4中的公式可以看出,有两种方法可以提高测试准确度。第一个是增加分子,信号:也就是说,使测试更善于发现回归。第二个是减少分母,噪音:使测试更善于不发出错误警报。
Another way to think of false positives and false negatives is in terms of signal-to-noise ratio. As you can see from the formula in figure 4.4, there are two ways to improve test accuracy. The first is to increase the numerator, signal: that is, make the test better at finding regressions. The second is to reduce the denominator, noise: make the test better at not raising false alarms.
两者都至关重要。如果测试无法发现任何错误,即使它没有发出错误警报,它也是毫无用处的。同样,如果测试产生大量噪音,即使它能够发现代码中的所有错误,其准确性也会降为零。这些发现只会淹没在无关信息的海洋中。
Both are critically important. There’s no use for a test that isn’t capable of finding any bugs, even if it doesn’t raise false alarms. Similarly, the test’s accuracy goes to zero when it generates a lot of noise, even if it’s capable of finding all the bugs in code. These findings are simply lost in the sea of irrelevant information.
从短期来看,误报并不像误报那么糟糕。在项目开始时,收到错误警告并不是什么大问题,相比之下,根本没有收到警告并冒着错误进入生产的风险。但随着项目的发展,误报开始对测试套件产生越来越大的影响(图 4.5)。
In the short term, false positives are not as bad as false negatives. In the beginning of a project, receiving a wrong warning is not that big a deal as opposed to not being warned at all and running the risk of a bug slipping into production. But as the project grows, false positives start to have an increasingly large effect on the test suite (figure 4.5).
为什么误报最初不那么重要?因为重构的重要性也不是立竿见影的;它会随着时间的推移逐渐增加。您不需要在项目开始时进行许多代码清理。新编写的代码通常光鲜亮丽、完美无缺。它还记忆犹新,因此即使测试引发误报,您也可以轻松重构它。
Why are false positives not as important initially? Because the importance of refactoring is also not immediate; it increases gradually over time. You don’t need to conduct many code clean-ups in the beginning of the project. Newly written code is often shiny and flawless. It’s also still fresh in your memory, so you can easily refactor it even if tests raise false alarms.
但随着时间的推移,代码库会逐渐恶化。它变得越来越复杂和混乱。因此,您必须开始定期进行重构以缓解这种趋势。否则,引入新功能的成本最终会变得过高。
But as time goes on, the code base deteriorates. It becomes increasingly complex and disorganized. Thus you have to start conducting regular refactorings in order to mitigate this tendency. Otherwise, the cost of introducing new features eventually becomes prohibitive.
随着重构需求的增加,测试中抵制重构的重要性也随之增加。正如我之前所解释的那样,当测试不断发出“狼来了”的警报并且你不断收到关于不存在的错误的警告时,你就无法进行重构。你很快就会对此类测试失去信任,不再将其视为可靠的反馈来源。
As the need for refactoring increases, the importance of resistance to refactoring in tests increases with it. As I explained earlier, you can’t refactor when the tests keep crying “wolf” and you keep getting warnings about bugs that don’t exist. You quickly lose trust in such tests and stop viewing them as a reliable source of feedback.
尽管保护代码免受误报的影响非常重要,尤其是在项目后期,但很少有开发人员以这种方式看待误报。大多数人往往只关注改进良好单元测试的第一个属性——防止回归,这不足以构建一个有价值、高度准确的测试套件,以帮助维持项目的增长。
Despite the importance of protecting your code against false positives, especially in the later project stages, few developers perceive false positives this way. Most people tend to focus solely on improving the first attribute of a good unit test—protection against regressions, which is not enough to build a valuable, highly accurate test suite that helps sustain project growth.
当然,原因在于进入后期阶段的项目要少得多,主要是因为它们规模较小,而且在项目变得太大之前开发就完成了。因此,开发人员面临的未被发现的错误问题比充斥整个项目并阻碍所有重构工作的误报问题要多得多。因此,人们会相应地进行优化。然而,如果你从事的是中型或大型项目,你必须对误报(未被发现的错误)和误报(误报)给予同等的关注。
The reason, of course, is that far fewer projects get to those later stages, mostly because they are small and the development finishes before the project becomes too big. Thus developers face the problem of unnoticed bugs more often than false alarms that swarm the project and hinder all refactoring undertakings. And so, people optimize accordingly. Nevertheless, if you work on a medium to large project, you have to pay equal attention to both false negatives (unnoticed bugs) and false positives (false alarms).
在本节中,我将讨论良好单元测试的另外两个支柱:
In this section, I talk about the two remaining pillars of a good unit test:
您可能还记得第 2 章的内容,快速反馈是单元测试的基本属性。测试速度越快,套件中可以包含的测试就越多,运行测试的频率就越高。
As you may remember from chapter 2, fast feedback is an essential property of a unit test. The faster the tests, the more of them you can have in the suite and the more often you can run them.
通过快速运行的测试,您可以大大缩短反馈循环,以至于测试会在您破坏代码时立即开始警告您有关错误,从而将修复这些错误的成本降低到几乎为零。另一方面,缓慢的测试会延迟反馈并可能延长错误未被发现的时间,从而增加修复它们的成本。这是因为缓慢的测试会阻止您经常运行它们,因此导致在错误的方向上浪费更多时间。
With tests that run quickly, you can drastically shorten the feedback loop, to the point where the tests begin to warn you about bugs as soon as you break the code, thus reducing the cost of fixing those bugs almost to zero. On the other hand, slow tests delay the feedback and potentially prolong the period during which the bugs remain unnoticed, thus increasing the cost of fixing them. That’s because slow tests discourage you from running them often, and therefore lead to wasting more time moving in a wrong direction.
最后,良好单元测试的第四个支柱,可维护性指标,评估维护成本。该指标由两个主要部分组成:
Finally, the fourth pillar of good units tests, the maintainability metric, evaluates maintenance costs. This metric consists of two major components:
以下是优秀单元测试的四个属性:
Here are the four attributes of a good unit test once again:
这四个属性相乘可确定测试的值。我所说的相乘是指数学意义上的相乘;也就是说,如果测试在其中一个属性中为零,则其值也会变为零:
These four attributes, when multiplied together, determine the value of a test. And by multiplied, I mean in a mathematical sense; that is, if a test gets zero in one of the attributes, its value turns to zero as well:
价值估计 = [0..1] * [0..1] * [0..1] * [0..1]
Value estimate = [0..1] * [0..1] * [0..1] * [0..1]
为了具有价值,测试需要在所有四个类别中至少获得一定的分数。
In order to be valuable, the test needs to score at least something in all four categories.
当然,不可能精确地测量这些属性。没有代码分析工具可以将测试插入其中并获得准确的数字。但您仍然可以相当准确地评估测试,以了解测试在四个属性方面的表现。反过来,这种评估会为您提供测试的价值估计,您可以使用它来决定是否将测试保留在套件中。
Of course, it’s impossible to measure these attributes precisely. There’s no code analysis tool you can plug a test into and get the exact numbers. But you can still evaluate the test pretty accurately to see where a test stands with regard to the four attributes. This evaluation, in turn, gives you the test’s value estimate, which you can use to decide whether to keep the test in the suite.
请记住,所有代码(包括测试代码)都是负担。为最低要求值设置一个相当高的阈值,并且只有达到此阈值的测试才允许加入套件。少量高价值测试比大量平庸的测试更能维持项目增长。
Remember, all code, including test code, is a liability. Set a fairly high threshold for the minimum required value, and only allow tests in the suite if they meet this threshold. A small number of highly valuable tests will do a much better job sustaining project growth than a large number of mediocre tests.
我稍后会展示一些示例。现在,让我们检查一下是否有可能创建一个理想的测试。
I’ll show some examples shortly. For now, let’s examine whether it’s possible to create an ideal test.
理想的测试是在所有四个属性上都获得最高分的测试。如果将最小值和最大值作为0每个1属性的得分,那么理想的测试必须1在所有属性上都获得分数。
An ideal test is a test that scores the maximum in all four attributes. If you take the minimum and maximum values as 0 and 1 for each of the attributes, an ideal test must get 1 in all of them.
不幸的是,不可能创建这样一个理想的测试。原因是前三个属性——防止回归、抵抗重构和快速反馈——是相互排斥的。不可能最大化它们全部:你必须牺牲三者之一才能最大化剩余的两个。
Unfortunately, it’s impossible to create such an ideal test. The reason is that the first three attributes—protection against regressions, resistance to refactoring, and fast feedback—are mutually exclusive. It’s impossible to maximize them all: you have to sacrifice one of the three in order to max out the remaining two.
此外,由于乘法原理(参见上一节中价值估计的计算),保持平衡变得更加棘手。您不能为了关注其他属性而放弃其中一个属性。正如我之前提到的,在四个类别中有一个类别得分为零的测试是毫无价值的。因此,您必须以不让任何属性被过度削弱的方式最大化这些属性。让我们看一些测试示例,这些测试旨在以牺牲第三个属性为代价最大化三个属性中的两个,结果其价值接近于零。
Moreover, because of the multiplication principle (see the calculation of the value estimate in the previous section), it’s even trickier to keep the balance. You can’t just forgo one of the attributes in order to focus on the others. As I mentioned previously, a test that scores zero in one of the four categories is worthless. Therefore, you have to maximize these attributes in such a way that none of them is diminished too much. Let’s look at some examples of tests that aim at maximizing two out of three attributes at the expense of the third and, as a result, have a value that’s close to zero.
第一个例子是端到端测试。您可能还记得第 2 章的内容,端到端测试从最终用户的角度看待系统。它们通常会检查系统的所有组件,包括 UI、数据库和外部应用程序。
The first example is end-to-end tests. As you may remember from chapter 2, end-to-end tests look at the system from the end user’s perspective. They normally go through all of the system’s components, including the UI, database, and external applications.
由于端到端测试会执行大量代码,因此它们能够提供最佳的回归保护。事实上,在所有类型的测试中,端到端测试执行的代码最多 — 包括您自己的代码以及您未编写但在项目中使用的代码,例如外部库、框架和第三方应用。
Since end-to-end tests exercise a lot of code, they provide the best protection against regressions. In fact, of all types of tests, end-to-end tests exercise the most code—both your code and the code you didn’t write but use in the project, such as external libraries, frameworks, and third-party applications.
端到端测试还不受误报的影响,因此具有良好的抗重构能力。如果正确完成重构,则不会改变系统的可观察行为,因此不会影响端到端测试。这是此类测试的另一个优点:它们不会强加任何特定的实现。端到端测试唯一关注的是从最终用户的角度来看某个功能的行为方式。它们尽可能远离实现细节。
End-to-end tests are also immune to false positives and thus have a good resistance to refactoring. A refactoring, if done correctly, doesn’t change the system’s observable behavior and therefore doesn’t affect the end-to-end tests. That’s another advantage of such tests: they don’t impose any particular implementation. The only thing end-to-end tests look at is how a feature behaves from the end user’s point of view. They are as removed from implementation details as tests could possibly be.
然而,尽管有这些好处,端到端测试也有一个主要缺点:速度很慢。任何仅依赖此类测试的系统都很难获得快速反馈。这对许多开发团队来说是一个难题。这就是为什么仅使用端到端测试几乎不可能覆盖您的代码库的原因。
However, despite these benefits, end-to-end tests have a major drawback: they are slow. Any system that relies solely on such tests would have a hard time getting rapid feedback. And that is a deal-breaker for many development teams. This is why it’s pretty much impossible to cover your code base with only end-to-end tests.
图 4.6显示了端到端测试在前三个单元测试指标方面的表现。此类测试可以很好地防止回归错误和误报,但速度较慢。
Figure 4.6 shows where end-to-end tests stand with regard to the first three unit testing metrics. Such tests provide great protection against both regression errors and false positives, but lack speed.
另一个以牺牲第三个属性为代价最大化三个属性中的两个属性的例子是简单测试。此类测试涵盖一段简单的代码,不太可能因为太简单而中断,如下面的清单所示。
Another example of maximizing two out of three attributes at the expense of the third is a trivial test. Such tests cover a simple piece of code, something that is unlikely to break because it’s too trivial, as shown in the following listing.
公共类用户
{
公共字符串名称 { 获取;设置; } 1
}
[事实]
公共无效测试()
{
var sut = 新用户();
sut.Name =“约翰·史密斯”;
断言.Equal("约翰·史密斯", sut.Name);
}public class User
{
public string Name { get; set; } 1
}
[Fact]
public void Test()
{
var sut = new User();
sut.Name = "John Smith";
Assert.Equal("John Smith", sut.Name);
}
与端到端测试不同,简单测试确实能提供快速反馈 — 它们运行速度非常快。它们产生误报的几率也相当低,因此它们对重构具有良好的抵抗力。然而,简单测试不太可能发现任何回归问题,因为底层代码中容错的空间不大。
Unlike end-to-end tests, trivial tests do provide fast feedback—they run very quickly. They also have a fairly low chance of producing a false positive, so they have good resistance to refactoring. Trivial tests are unlikely to reveal any regressions, though, because there’s not much room for a mistake in the underlying code.
简单测试发展到极端,就会导致同义反复测试。它们不测试任何东西,因为它们的设置方式使得它们总是通过或包含语义上无意义的断言。
Trivial tests taken to an extreme result in tautology tests. They don’t test anything because they are set up in such a way that they always pass or contain semantically meaningless assertions.
图 4.7显示了简单测试的情况。它们具有良好的抗重构能力,并提供快速反馈,但它们无法保护您免受回归的影响。
Figure 4.7 shows where trivial tests stand. They have good resistance to refactoring and provide fast feedback, but they don’t protect you from regressions.
同样,编写一个运行速度快、很有可能发现回归问题的测试也很容易,但这样做会产生很多误报。这样的测试被称为脆弱测试:它无法承受重构,无论底层功能是否损坏,它都会变成红色。
Similarly, it’s pretty easy to write a test that runs fast and has a good chance of catching a regression but does so with a lot of false positives. Such a test is called a brittle test: it can’t withstand a refactoring and will turn red regardless of whether the underlying functionality is broken.
您已经在清单 4.2中看到了脆性测试的一个例子。这是另一个。
You already saw an example of a brittle test in listing 4.2. Here’s another one.
公共类用户存储库
{
公共用户 GetById(int id)
{
/* ... */
}
公共字符串 LastExecutedSqlStatement { 获取; 设置; }
}
[事实]
公共无效GetById_executes_correct_SQL_code()
{
var sut = new UserRepository();
用户用户 = sut.GetById(5);
断言.等于(
“从 dbo 中选择 *。[用户] 其中用户 ID = 5”,
sut.LastExecutedSqlStatement);
}public class UserRepository
{
public User GetById(int id)
{
/* ... */
}
public string LastExecutedSqlStatement { get; set; }
}
[Fact]
public void GetById_executes_correct_SQL_code()
{
var sut = new UserRepository();
User user = sut.GetById(5);
Assert.Equal(
"SELECT * FROM dbo.[User] WHERE UserID = 5",
sut.LastExecutedSqlStatement);
}
此测试确保UserRepository类在从数据库获取用户时生成正确的 SQL 语句。此测试能发现错误吗?可以。例如,开发人员可能会搞乱 SQL 代码生成并错误地使用ID而不是UserID,测试将通过引发失败来指出这一点。但此测试是否具有良好的抗重构性?绝对不是。以下是导致相同结果的 SQL 语句的不同变体:
This test makes sure the UserRepository class generates a correct SQL statement when fetching a user from the database. Can this test catch a bug? It can. For example, a developer can mess up the SQL code generation and mistakenly use ID instead of UserID, and the test will point that out by raising a failure. But does this test have good resistance to refactoring? Absolutely not. Here are different variations of the SQL statement that lead to the same result:
从 dbo 中选择 *。[用户] 其中用户 ID = 5 从 dbo.User 中选择 *,其中 UserID = 5 从 dbo 中选择用户 ID、姓名、电子邮件。[用户] 其中用户 ID = 5 从 dbo 中选择 *。[用户] 其中用户 ID = @用户 ID
SELECT * FROM dbo.[User] WHERE UserID = 5 SELECT * FROM dbo.User WHERE UserID = 5 SELECT UserID, Name, Email FROM dbo.[User] WHERE UserID = 5 SELECT * FROM dbo.[User] WHERE UserID = @UserID
如果将 SQL 脚本更改为上述任何一种变体,即使功能本身仍可运行,清单 4.6中的测试也会变成红色。这再次是将测试与 SUT 的内部实现细节相结合的一个例子。测试侧重于如何而不是什么,从而根深蒂固地保留了 SUT 的实现细节,防止任何进一步的重构。
The test in listing 4.6 will turn red if you change the SQL script to any of these variations, even though the functionality itself will remain operational. This is once again an example of coupling the test to the SUT’s internal implementation details. The test is focusing on hows instead of whats and thus ingrains the SUT’s implementation details, preventing any further refactoring.
图 4.8表明脆弱测试属于第三类。此类测试运行速度快,能够很好地防止回归,但对重构的抵抗力较弱。
Figure 4.8 shows that brittle tests fall into the third bucket. Such tests run fast and provide good protection against regressions but have little resistance to refactoring.
好的单元测试的前三个属性(防止回归、抗重构和快速反馈)是相互排斥的。虽然很容易想出一个测试来最大化这三个属性中的两个,但你只能以牺牲第三个属性为代价来做到这一点。不过,由于乘法规则,这样的测试将具有接近于零的值。不幸的是,不可能创建一个在这三个属性上都获得满分的理想测试(图 4.9)。
The first three attributes of a good unit test (protection against regressions, resistance to refactoring, and fast feedback) are mutually exclusive. While it’s quite easy to come up with a test that maximizes two out of these three attributes, you can only do that at the expense of the third. Still, such a test would have a close-to-zero value due to the multiplication rule. Unfortunately, it’s impossible to create an ideal test that has a perfect score in all three attributes (figure 4.9).
第四个属性,可维护性,与前三个属性无关,但端到端测试除外。端到端测试通常规模较大,因为需要设置此类测试涉及的所有依赖项。它们还需要额外的努力来保持这些依赖项正常运行。因此,端到端测试在维护成本方面往往更昂贵。
The fourth attribute, maintainability, is not correlated to the first three, with the exception of end-to-end tests. End-to-end tests are normally larger in size because of the necessity to set up all the dependencies such tests reach out to. They also require additional effort to keep those dependencies operational. Hence end-to-end tests tend to be more expensive in terms of maintenance costs.
很难在良好测试的各项属性之间保持平衡。测试不可能在前三个类别中都获得最高分,而且您还必须关注可维护性方面,以便测试保持合理的简短和简单。因此,您必须做出权衡。此外,您应该以这样的方式进行权衡,即没有一个特定属性变为零。牺牲必须是部分的和战略性的。
It’s hard to keep a balance between the attributes of a good test. A test can’t have the maximum score in each of the first three categories, and you also have to keep an eye on the maintainability aspect so the test remains reasonably short and simple. Therefore, you have to make trade-offs. Moreover, you should make those trade-offs in such a way that no particular attribute turns to zero. The sacrifices have to be partial and strategic.
这些牺牲应该是什么样子的?由于防止回归、抵制重构和快速反馈的相互排斥性,你可能会认为最好的策略是各让步一点:刚好足以为所有三个属性腾出空间。
What should those sacrifices look like? Because of the mutual exclusiveness of protection against regressions, resistance to refactoring, and fast feedback, you may think that the best strategy is to concede a little bit of each: just enough to make room for all three attributes.
但实际上,重构阻力是不可协商的。你应该尽可能地争取这种阻力,前提是你的测试保持合理的速度,并且你不依赖端到端测试的独家使用。那么,权衡就归结为在测试指出错误的能力和它们指出错误的速度之间进行选择:即在防止回归和快速反馈之间进行选择。你可以将这个选择看作一个滑块,可以在防止回归和快速反馈之间自由移动。你在某一属性上获得越多,你在另一个属性上失去的就越多(见图4.10)。
In reality, though, resistance to refactoring is non-negotiable. You should aim at gaining as much of it as you can, provided that your tests remain reasonably quick and you don’t resort to the exclusive use of end-to-end tests. The trade-off, then, comes down to the choice between how good your tests are at pointing out bugs and how fast they do that: that is, between protection against regressions and fast feedback. You can view this choice as a slider that can be freely moved between protection against regressions and fast feedback. The more you gain in one attribute, the more you lose on the other (see figure 4.10).
抵制重构是不可商榷的,因为测试是否具有这种属性主要是一个二元选择:测试要么抵制重构,要么不抵制。两者之间几乎没有中间阶段。因此你不能让步只是对重构有一点抵触:你将不得不失去一切。另一方面,防止回归和快速反馈的指标更具可塑性。在下一节中,你将看到当你选择其中一个而不是另一个时可能会有什么样的权衡。
The reason resistance to refactoring is non-negotiable is that whether a test possesses this attribute is mostly a binary choice: the test either has resistance to refactoring or it doesn’t. There are almost no intermediate stages in between. Thus you can’t concede just a little resistance to refactoring: you’ll have to lose it all. On the other hand, the metrics of protection against regressions and fast feedback are more malleable. You will see in the next section what kind of trade-offs are possible when you choose one over the other.
消除测试中的脆弱性(误报)是实现强大测试套件的首要任务。
Eradicating brittleness (false positives) in tests is the first priority on the path to a robust test suite.
良好单元测试的前三个属性之间的权衡类似于 CAP 定理。CAP 定理指出,分布式数据存储不可能同时提供以下三个保证中的两个以上:
The trade-off between the first three attributes of a good unit test is similar to the CAP theorem. The CAP theorem states that it is impossible for a distributed data store to simultaneously provide more than two of the following three guarantees:
相似之处有两点:
The similarity is two-fold:
因此,选择也归结为一致性和可用性之间的权衡。在系统的某些部分,最好放弃一点一致性以获得更高的可用性。例如,在显示产品目录时,如果目录的某些部分已过期,通常也没问题。在这种情况下,可用性具有更高的优先级。另一方面,在更新产品描述时,一致性比可用性更重要:网络节点必须就该描述的最新版本达成共识,以避免合并冲突。
The choice, then, also boils down to a trade-off between consistency and availability. In some parts of the system, it’s preferable to concede a little consistency to gain more availability. For example, when displaying a product catalog, it’s generally fine if some parts of the catalog are out of date. Availability is of higher priority in this scenario. On the other hand, when updating a product description, consistency is more important than availability: network nodes must have a consensus on what the most recent version of that description is, in order to avoid merge conflicts.
前面提到的良好单元测试的四个属性是基础。所有现有的、众所周知的测试自动化概念都可以追溯到这四个属性。在本节中,我们将讨论两个这样的概念:测试金字塔和白盒测试与黑盒测试。
The four attributes of a good unit test shown earlier are foundational. All existing, well-known test automation concepts can be traced back to these four attributes. In this section, we’ll look at two such concepts: the Test Pyramid and white-box versus black-box testing.
测试金字塔是一种主张测试套件中不同类型测试按一定比例分布的概念(图 4.11):
The Test Pyramid is a concept that advocates for a certain ratio of different types of tests in the test suite (figure 4.11):
测试金字塔通常以金字塔的形式呈现,其中包含这三种类型的测试。金字塔层的宽度指的是特定类型的流行程度套件中的测试。层越宽,测试数量越多。层的高度衡量这些测试与模拟最终用户行为的接近程度。端到端测试位于顶层 — 它们最接近模仿用户体验。金字塔中的不同类型的测试在快速反馈和防止回归之间的权衡中做出不同的选择。金字塔较高层的测试更倾向于防止回归,而较低层的测试则强调执行速度(图 4.12)。
The Test Pyramid is often represented visually as a pyramid with those three types of tests in it. The width of the pyramid layers refers to the prevalence of a particular type of test in the suite. The wider the layer, the greater the test count. The height of the layer is a measure of how close these tests are to emulating the end user’s behavior. End-to-end tests are at the top—they are the closest to imitating the user experience. Different types of tests in the pyramid make different choices in the trade-off between fast feedback and protection against regressions. Tests in higher pyramid layers favor protection against regressions, while lower layers emphasize execution speed (figure 4.12).
请注意,两层都不会放弃对重构的抵抗。当然,端到端和集成测试在此指标上的得分高于单元测试,但这只是与生产代码分离程度更高的副作用。不过,即使是单元测试也不应该放弃对重构的抵抗。所有测试都应旨在产生尽可能少的误报,即使直接使用生产代码也是如此。(如何做到这一点是下一章的主题。)
Notice that neither layer gives up resistance to refactoring. Naturally, end-to-end and integration tests score higher on this metric than unit tests, but only as a side effect of being more detached from the production code. Still, even unit tests should not concede resistance to refactoring. All tests should aim at producing as few false positives as possible, even when working directly with the production code. (How to do that is the topic of the next chapter.)
每个团队和项目的具体测试类型组合会有所不同。但总体而言,它应该保持金字塔形状:端到端测试应该是少数;单元测试是多数;集成测试处于中间位置。
The exact mix between types of tests will be different for each team and project. But in general, it should retain the pyramid shape: end-to-end tests should be the minority; unit tests, the majority; and integration tests somewhere in the middle.
端到端测试之所以是少数,原因同样是第 4.4 节中描述的乘法规则。端到端测试在快速反馈指标上的得分极低。它们还缺乏可维护性:它们往往规模较大,并且需要额外的努力来维护所涉及的进程外依赖关系。因此,端到端测试只有在应用于最关键的功能时才有意义——您永远不希望看到任何错误 — 并且只有当您无法通过单元测试或集成测试获得相同程度的保护时才这样做。对其他任何事情使用端到端测试都不应超过您的最低要求值阈值。单元测试通常更加平衡,因此您通常会有更多单元测试。
The reason end-to-end tests are the minority is, again, the multiplication rule described in section 4.4. End-to-end tests score extremely low on the metric of fast feedback. They also lack maintainability: they tend to be larger in size and require additional effort to maintain the involved out-of-process dependencies. Thus, end-to-end tests only make sense when applied to the most critical functionality—features in which you don’t ever want to see any bugs—and only when you can’t get the same degree of protection with unit or integration tests. The use of end-to-end tests for anything else shouldn’t pass your minimum required value threshold. Unit tests are usually more balanced, and hence you normally have many more of them.
测试金字塔也有例外。例如,如果您的应用程序只执行基本的创建、读取、更新和删除 (CRUD) 操作,且几乎没有业务规则或任何其他复杂性,那么您的测试“金字塔”很可能看起来像一个矩形,其中包含相同数量的单元测试和集成测试,但没有端到端测试。
There are exceptions to the Test Pyramid. For example, if all your application does is basic create, read, update, and delete (CRUD) operations with very few business rules or any other complexity, your test “pyramid” will most likely look like a rectangle with an equal number of unit and integration tests and no end-to-end tests.
在没有算法或业务复杂性的环境中,单元测试用处不大——它们很快就会沦为琐碎的测试。与此同时,集成测试仍然有其价值——验证代码(无论多么简单)如何与其他子系统(如数据库)集成工作仍然很重要。因此,您最终可能会得到更少的单元测试和更多的集成测试。在最简单的例子中,集成测试的数量甚至可能大于单元测试的数量。
Unit tests are less useful in a setting without algorithmic or business complexity—they quickly descend into trivial tests. At the same time, integration tests retain their value—it’s still important to verify how code, however simple it is, works in integration with other subsystems, such as the database. As a result, you may end up with fewer unit tests and more integration tests. In the most trivial examples, the number of integration tests may even be greater than the number of unit tests.
测试金字塔的另一个例外是与单个进程外依赖项(例如数据库)相关的 API。对于这样的应用程序,进行更多的端到端测试可能是可行的选择。由于没有用户界面,端到端测试的运行速度相当快。维护成本也不会太高,因为您只使用单个外部依赖项(数据库)。基本上,在这种环境下,端到端测试与集成测试没有区别。唯一的不同是入口点:端到端测试要求将应用程序托管在某个地方以完全模拟最终用户,而集成测试通常将应用程序托管在同一进程中。我们将在第8 章中讨论集成测试时回到测试金字塔。
Another exception to the Test Pyramid is an API that reaches out to a single out-of-process dependency—say, a database. Having more end-to-end tests may be a viable option for such an application. Since there’s no user interface, end-to-end tests will run reasonably fast. The maintenance costs won’t be too high, either, because you only work with the single external dependency, the database. Basically, end-to-end tests are indistinguishable from integration tests in this environment. The only thing that differs is the entry point: end-to-end tests require the application to be hosted somewhere to fully emulate the end user, while integration tests normally host the application in the same process. We’ll get back to the Test Pyramid in chapter 8, when we’ll be talking about integration testing.
另一个众所周知的测试自动化概念是黑盒测试与白盒测试。在本节中,我将展示何时使用这两种方法:
The other well-known test automation concept is black-box versus white-box testing. In this section, I show when to use each of the two approaches:
这两种方法各有利弊。白盒测试往往更为彻底。通过分析源代码,你可以发现许多仅依赖外部规范时可能会错过的错误。另一方面,白盒测试产生的测试通常很脆弱,因为它们往往与被测代码的具体实现紧密耦合。此类测试会产生许多误报,因此在抗重构性指标上达不到要求。它们通常也无法追踪回到对业务人员有意义的行为,这强烈表明这些测试很脆弱,不会增加太多价值。黑盒测试提供了相反的优点和缺点(表 4.1)。
There are pros and cons to both of these methods. White-box testing tends to be more thorough. By analyzing the source code, you can uncover a lot of errors that you may miss when relying solely on external specifications. On the other hand, tests resulting from white-box testing are often brittle, as they tend to tightly couple to the specific implementation of the code under test. Such tests produce many false positives and thus fall short on the metric of resistance to refactoring. They also often can’t be traced back to a behavior that is meaningful to a business person, which is a strong sign that these tests are fragile and don’t add much value. Black-box testing provides the opposite set of pros and cons (table 4.1).
|
防止回归 Protection against regressions |
抵制重构 Resistance to refactoring |
|
|---|---|---|
| 白盒测试 | 好的 | 坏的 |
| 黑盒测试 | 坏的 | 好的 |
您可能还记得第 4.4.5 节的内容,您不能在抗重构性上妥协:测试要么具有抗重构性,要么不具有。因此,默认情况下,选择黑盒测试而不是白盒测试。让所有测试(无论是单元测试、集成测试还是端到端测试)将系统视为黑盒,并验证对问题域有意义的行为。如果您无法将测试追溯到业务需求,则表明测试很脆弱。要么重构,要么删除此测试;不要让它原封不动地进入套件。唯一的例外是当测试涵盖具有高算法复杂度的实用程序代码时(有关此内容的更多信息,请参见第 7 章)。
As you may remember from section 4.4.5, you can’t compromise on resistance to refactoring: a test either possesses resistance to refactoring or it doesn’t. Therefore, choose black-box testing over white-box testing by default. Make all tests—be they unit, integration, or end-to-end—view the system as a black box and verify behavior meaningful to the problem domain. If you can’t trace a test back to a business requirement, it’s an indication of the test’s brittleness. Either restructure or delete this test; don’t let it into the suite as-is. The only exception is when the test covers utility code with high algorithmic complexity (more on this in chapter 7).
请注意,虽然编写测试时最好使用黑盒测试,但分析测试时仍可以使用白盒方法。使用代码覆盖工具查看哪些代码分支未被执行,然后回头测试它们,就好像您对代码的内部结构一无所知一样。这种白盒和黑盒方法的结合效果最好。
Note that even though black-box testing is preferable when writing tests, you can still use the white-box method when analyzing the tests. Use code coverage tools to see which code branches are not exercised, but then turn around and test them as if you know nothing about the code’s internal structure. Such a combination of the white-box and black-box methods works best.
第 4 章介绍了可用于分析特定测试和单元测试方法的参考框架。在本章中,您将看到该参考框架的实际应用;我们将使用它来剖析模拟主题。
Chapter 4 introduced a frame of reference that you can use to analyze specific tests and unit testing approaches. In this chapter, you’ll see that frame of reference in action; we’ll use it to dissect the topic of mocks.
在测试中使用模拟是一个有争议的话题。有些人认为模拟是一个很好的工具,并在大多数测试中应用它们。另一些人声称模拟会导致测试脆弱,并尽量不使用它们。俗话说,真相介于两者之间。在本章中,我将展示,模拟确实经常导致脆弱的测试——缺乏抗重构指标的测试。但在某些情况下,模拟是适用的,甚至是可取的。
The use of mocks in tests is a controversial subject. Some people argue that mocks are a great tool and apply them in most of their tests. Others claim that mocks lead to test fragility and try not to use them at all. As the saying goes, the truth lies somewhere in between. In this chapter, I’ll show that, indeed, mocks often result in fragile tests—tests that lack the metric of resistance to refactoring. But there are still cases where mocking is applicable and even preferable.
本章主要参考了第 2 章中关于伦敦学派与传统学派单元测试的讨论。简而言之,两个学派之间的分歧源于他们对测试隔离问题的看法。伦敦学派主张将测试中的代码片段彼此隔离,并对除不可变依赖项之外的所有代码片段使用测试替身来执行这种隔离。
This chapter draws heavily on the discussion about the London versus classical schools of unit testing from chapter 2. In short, the disagreement between the schools stems from their views on the test isolation issue. The London school advocates isolating pieces of code under test from each other and using test doubles for all but immutable dependencies to perform such isolation.
经典学派主张隔离单元测试本身,以便它们可以并行运行。该学派仅对测试之间共享的依赖项使用测试替身。
The classical school stands for isolating unit tests themselves so that they can be run in parallel. This school uses test doubles only for dependencies that are shared between tests.
模拟和测试脆弱性之间存在着深刻且几乎不可避免的联系。在接下来的几节中,我将逐步为您奠定基础,让您了解这种联系存在的原因。您还将学习如何使用模拟,以便它们不会损害测试对重构的抵抗力。
There’s a deep and almost inevitable connection between mocks and test fragility. In the next several sections, I will gradually lay down the foundation for you to see why that connection exists. You will also learn how to use mocks so that they don’t compromise a test’s resistance to refactoring.
在第 2 章中,我简要提到过,模拟是一种测试替身,它允许您检查被测系统 (SUT) 与其协作者之间的交互。还有另一种类型的测试替身:存根。让我们仔细看看模拟是什么以及它与存根有何不同。
In chapter 2, I briefly mentioned that a mock is a test double that allows you to examine interactions between the system under test (SUT) and its collaborators. There’s another type of test double: a stub. Let’s take a closer look at what a mock is and how it is different from a stub.
测试替身是一个概括性术语,用于描述测试中所有类型的非生产就绪的虚假依赖项。该术语源自电影中的替身概念。测试替身的主要用途是促进测试;它们被传递给被测系统,而不是真实的依赖项,因为真实的依赖项可能很难设置或维护。
A test double is an overarching term that describes all kinds of non-production-ready, fake dependencies in tests. The term comes from the notion of a stunt double in a movie. The major use of test doubles is to facilitate testing; they are passed to the system under test instead of real dependencies, which could be hard to set up or maintain.
根据 Gerard Meszaros 的说法,测试替身有 5 种变体:dummy、stub、spy、mock和fake。[ 1 ]这样的变体看上去有些吓人,但实际上,它们可以归为两种类型:mock 和 stub(图 5.1)。
According to Gerard Meszaros, there are five variations of test doubles: dummy, stub, spy, mock, and fake.[1] Such a variety can look intimidating, but in reality, they can all be grouped together into just two types: mocks and stubs (figure 5.1).
参见xUnit 测试模式:重构测试代码(Addison-Wesley,2007)。
See xUnit Test Patterns: Refactoring Test Code (Addison-Wesley, 2007).
The difference between these two types boils down to the following:
这五种变体之间的所有其他差异都是无关紧要的实现细节。例如,间谍的作用与模拟相同。区别在于间谍是手动编写的,而模拟是在模拟框架的帮助下创建的。有时人们将间谍称为手写模拟。
All other differences between the five variations are insignificant implementation details. For example, spies serve the same role as mocks. The distinction is that spies are written manually, whereas mocks are created with the help of a mocking framework. Sometimes people refer to spies as handwritten mocks.
另一方面,存根、虚拟和伪造之间的区别在于它们的智能程度。虚拟是一个简单的硬编码值,例如空值或虚构的字符串。它用于满足 SUT 的方法签名,不参与生成最终结果。存根更复杂。它是一个完全成熟的依赖项,您可以将其配置为针对不同场景返回不同的值。最后,伪造在大多数情况下与存根相同。区别在于其创建的理由:伪造通常用于替换尚不存在的依赖项。
On the other hand, the difference between a stub, a dummy, and a fake is in how intelligent they are. A dummy is a simple, hardcoded value such as a null value or a made-up string. It’s used to satisfy the SUT’s method signature and doesn’t participate in producing the final outcome. A stub is more sophisticated. It’s a fully fledged dependency that you configure to return different values for different scenarios. Finally, a fake is the same as a stub for most purposes. The difference is in the rationale for its creation: a fake is usually implemented to replace a dependency that doesn’t yet exist.
注意模拟和存根之间的区别(除了输出与输入交互之外)。模拟有助于模拟和检查SUT 及其依赖项之间的交互,而存根仅有助于模拟这些交互。这是一个重要的区别。您很快就会明白为什么。
Notice the difference between mocks and stubs (aside from outcoming versus incoming interactions). Mocks help to emulate and examine interactions between the SUT and its dependencies, while stubs only help to emulate those interactions. This is an important distinction. You will see why shortly.
术语“mock”含义丰富,在不同情况下可能有不同的含义。我在第 2 章中提到,人们经常用这个术语来表示任何测试替身,而 mock 只是测试替身的一个子集。但术语“mock”还有另一层含义。您也可以将 mock 库中的类称为 mock。这些类可帮助您创建实际的模拟,但它们本身并不是模拟。以下清单显示了一个示例。
The term mock is overloaded and can mean different things in different circumstances. I mentioned in chapter 2 that people often use this term to mean any test double, whereas mocks are only a subset of test doubles. But there’s another meaning for the term mock. You can refer to the classes from mocking libraries as mocks, too. These classes help you create actual mocks, but they themselves are not mocks per se. The following listing shows an example.
[事实]
公共无效发送问候电子邮件()
{
var mock = new Mock<IEmailGateway>(); 1
var sut = new Controller(mock.Object);
sut.GreetUser(“user@email.com”);
模拟.验证( 2
x => x.SendGreetingsEmail( 2
“user@email.com”), 2
Times.Once); 2
}[Fact]
public void Sending_a_greetings_email()
{
var mock = new Mock<IEmailGateway>(); 1
var sut = new Controller(mock.Object);
sut.GreetUser("user@email.com");
mock.Verify( 2
x => x.SendGreetingsEmail( 2
"user@email.com"), 2
Times.Once); 2
}
清单 5.1中的测试使用了Mock我选择的模拟库 (Moq) 中的类。该类是一个工具,可用于创建测试替身,即模拟。换句话说,类Mock(或Mock<IEmailGateway>)是模拟(工具),而该类的实例mock是模拟(测试替身)。重要的是不要将模拟(工具)与模拟(测试替身)混为一谈,因为您可以使用模拟(工具)来创建两种类型的测试替身:模拟和存根。
The test in listing 5.1 uses the Mock class from the mocking library of my choice (Moq). This class is a tool that enables you to create a test double—a mock. In other words, the class Mock (or Mock<IEmailGateway>) is a mock (the tool), while the instance of that class, mock, is a mock (the test double). It’s important not to conflate a mock (the tool) with a mock (the test double) because you can use a mock (the tool) to create both types of test doubles: mocks and stubs.
以下清单中的测试也使用了该类Mock,但该类的实例不是模拟,而是存根。
The test in the following listing also uses the Mock class, but the instance of that class is not a mock, it’s a stub.
[事实]
公共无效创建报告()
{
var stub = new Mock<IDatabase>(); 1
stub.Setup(x => x.GetNumberOfUsers()) 2
.Returns(10); 2
var sut = new Controller(stub.Object);
报告报告 = sut.CreateReport();
断言.等于(10,报告.用户数);
}[Fact]
public void Creating_a_report()
{
var stub = new Mock<IDatabase>(); 1
stub.Setup(x => x.GetNumberOfUsers()) 2
.Returns(10); 2
var sut = new Controller(stub.Object);
Report report = sut.CreateReport();
Assert.Equal(10, report.NumberOfUsers);
}
此测试替身模拟传入交互——向 SUT 提供输入数据的调用。另一方面,在上一个示例(清单 5.1)中,对SendGreetingsEmail()是一种输出性互动。它的唯一目的是产生副作用——发送电子邮件。
This test double emulates an incoming interaction—a call that provides the SUT with input data. On the other hand, in the previous example (listing 5.1), the call to SendGreetingsEmail() is an outcoming interaction. Its sole purpose is to incur a side effect—send an email.
正如我在第 5.1.1 节中提到的,模拟有助于模拟和检查SUT 及其依赖项之间的输出交互,而存根仅有助于模拟传入交互,而不是检查它们。两者之间的差异源于永远不要断言与存根交互的准则。从 SUT 对存根的调用不是 SUT 产生的最终结果的一部分。这样的调用只是产生最终结果的一种手段:存根提供输入,然后 SUT 从中生成输出。
As I mentioned in section 5.1.1, mocks help to emulate and examine outcoming interactions between the SUT and its dependencies, while stubs only help to emulate incoming interactions, not examine them. The difference between the two stems from the guideline of never asserting interactions with stubs. A call from the SUT to a stub is not part of the end result the SUT produces. Such a call is only a means to produce the end result: a stub provides input from which the SUT then generates the output.
断言与存根的交互是一种常见的反模式,会导致测试脆弱。
Asserting interactions with stubs is a common anti-pattern that leads to fragile tests.
您可能还记得第 4 章的内容,避免误报并因此提高测试对重构的抵抗力的唯一方法是让这些测试验证最终结果(理想情况下,对非程序员来说应该是有意义的),而不是实现细节。在清单 5.1中,检查
As you might remember from chapter 4, the only way to avoid false positives and thus improve resistance to refactoring in tests is to make those tests verify the end result (which, ideally, should be meaningful to a non-programmer), not implementation details. In listing 5.1, the check
模拟.验证(x => x.SendGreetingsEmail(“user@email.com”))
mock.Verify(x => x.SendGreetingsEmail("user@email.com"))
对应于实际结果,并且该结果对领域专家有意义:发送问候电子邮件是业务人员希望系统执行的操作。同时,清单 5.2GetNumberOfUsers()中的调用根本不是结果。它是关于 SUT 如何收集报告创建所需数据的内部实现细节。因此,断言此调用会导致测试脆弱性:只要结果正确,SUT 如何生成最终结果就无关紧要。以下清单显示了这种脆弱测试的一个例子。
corresponds to an actual outcome, and that outcome is meaningful to a domain expert: sending a greetings email is something business people would want the system to do. At the same time, the call to GetNumberOfUsers() in listing 5.2 is not an outcome at all. It’s an internal implementation detail regarding how the SUT gathers data necessary for the report creation. Therefore, asserting this call would lead to test fragility: it shouldn’t matter how the SUT generates the end result, as long as that result is correct. The following listing shows an example of such a brittle test.
[事实]
公共无效创建报告()
{
var Stub = new Mock<IDatabase>();
stub.设置(x => x.GetNumberOfUsers())。返回(10);
var sut = new Controller(stub.Object);
报告报告 = sut.CreateReport();
断言.等于(10,报告.用户数);
stub.Verify( 1
x => x.GetNumberOfUsers(), 1
Times.Once); 1
}[Fact]
public void Creating_a_report()
{
var stub = new Mock<IDatabase>();
stub.Setup(x => x.GetNumberOfUsers()).Returns(10);
var sut = new Controller(stub.Object);
Report report = sut.CreateReport();
Assert.Equal(10, report.NumberOfUsers);
stub.Verify( 1
x => x.GetNumberOfUsers(), 1
Times.Once); 1
}
这种验证不属于最终结果的事物的做法也称为过度规范。最常见的是,过度规范发生在检查交互时。检查与存根的交互是一个很容易发现的缺陷,因为测试不应该检查与存根的任何交互。模拟是一个更复杂的主题:并非所有模拟的使用都会导致测试脆弱性,但很多模拟都会导致测试脆弱性。您将在本章后面看到原因。
This practice of verifying things that aren’t part of the end result is also called over-specification. Most commonly, overspecification takes place when examining interactions. Checking for interactions with stubs is a flaw that’s quite easy to spot because tests shouldn’t check for any interactions with stubs. Mocks are a more complicated subject: not all uses of mocks lead to test fragility, but a lot of them do. You’ll see why later in this chapter.
有时您需要创建一个同时展现模拟和存根属性的测试替身。例如,这是我用来说明伦敦式单元测试的第 2 章中的测试。
Sometimes you need to create a test double that exhibits the properties of both a mock and a stub. For example, here’s a test from chapter 2 that I used to illustrate the London style of unit testing.
[事实]
public void Purchase_fails_when_not_enough_inventory()
{
var storeMock = new Mock<IStore>();
storeMock 1.
设置(x => x.HasEnoughInventory( 1
Product.Shampoo,5)) 1
.返回(false); 1
var sut = 新客户();
bool 成功 = sut.购买(
storeMock.Object,产品.洗发水,5);
断言.False(成功);
storeMock.Verify( 2
x => x.RemoveInventory(Product.Shampoo, 5), 2
Times.Never); 2
}[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
var storeMock = new Mock<IStore>();
storeMock 1
.Setup(x => x.HasEnoughInventory( 1
Product.Shampoo, 5)) 1
.Returns(false); 1
var sut = new Customer();
bool success = sut.Purchase(
storeMock.Object, Product.Shampoo, 5);
Assert.False(success);
storeMock.Verify( 2
x => x.RemoveInventory(Product.Shampoo, 5), 2
Times.Never); 2
}
此测试有storeMock两个目的:它返回预设答案并验证 SUT 进行的方法调用。但请注意,这是两种不同的方法:测试设置来自的答案HasEnoughInventory(),然后验证对的调用RemoveInventory()。因此,这里没有违反不声明与存根交互的规则。
This test uses storeMock for two purposes: it returns a canned answer and verifies a method call made by the SUT. Notice, though, that these are two different methods: the test sets up the answer from HasEnoughInventory() but then verifies the call to RemoveInventory(). Thus, the rule of not asserting interactions with stubs is not violated here.
当测试替身既是模拟又是存根时,它仍被称为模拟,而不是存根。这主要是因为我们需要选择一个名称,但也因为模拟比存根更重要。
When a test double is both a mock and a stub, it’s still called a mock, not a stub. That’s mostly the case because we need to pick one name, but also because being a mock is a more important fact than being a stub.
模拟和存根的概念与命令查询分离 (CQS) 原则相关。CQS 原则规定,每种方法都应该是命令或查询,但不能同时使用两者。如图 5.3所示,命令是产生副作用且不返回任何值(return void)的方法。副作用的示例包括改变对象的状态、更改文件系统中的文件等。查询则相反 - 它们没有副作用并且返回一个值。
The notions of mocks and stubs tie to the command query separation (CQS) principle. The CQS principle states that every method should be either a command or a query, but not both. As shown in figure 5.3, commands are methods that produce side effects and don’t return any value (return void). Examples of side effects include mutating an object’s state, changing a file in the file system, and so on. Queries are the opposite of that—they are side-effect free and return a value.
要遵循这一原则,请确保如果某个方法产生副作用,则该方法的返回类型为void。如果该方法返回一个值,则它必须保持无副作用。换句话说,提出问题不应该改变答案。保持这种明确分离的代码将变得更容易阅读。您只需查看方法的签名即可知道该方法的作用,而无需深入了解其实现细节。
To follow this principle, be sure that if a method produces a side effect, that method’s return type is void. And if the method returns a value, it must stay side-effect free. In other words, asking a question should not change the answer. Code that maintains such a clear separation becomes easier to read. You can tell what a method does just by looking at its signature, without diving into its implementation details.
当然,并非总是可以遵循 CQS 原则。总有一些方法既会产生副作用又会返回值。一个典型的例子是stack.Pop()。此方法既会从堆栈中删除顶部元素,又会将其返回给调用者。不过,只要有可能,最好还是遵守 CQS 原则。
Of course, it’s not always possible to follow the CQS principle. There are always methods for which it makes sense to both incur a side effect and return a value. A classical example is stack.Pop(). This method both removes a top element from the stack and returns it to the caller. Still, it’s a good idea to adhere to the CQS principle whenever you can.
替代命令的测试替身成为模拟。类似地,替代查询的测试替身是存根。再看看清单 5.1和5.2中的两个测试(我在这里展示它们的相关部分):
Test doubles that substitute commands become mocks. Similarly, test doubles that substitute queries are stubs. Look at the two tests from listings 5.1 and 5.2 again (I’m showing their relevant parts here):
var mock = new Mock<IEmailGateway>(); 模拟.验证(x => x.SendGreetingsEmail(“user@email.com”)); var Stub = new Mock<IDatabase>(); stub.设置(x => x.GetNumberOfUsers())。返回(10);
var mock = new Mock<IEmailGateway>();
mock.Verify(x => x.SendGreetingsEmail("user@email.com"));
var stub = new Mock<IDatabase>();
stub.Setup(x => x.GetNumberOfUsers()).Returns(10);
SendGreetingsEmail()是一个副作用是发送电子邮件的命令。替代此命令的测试替身是一个模拟。另一方面,GetNumberOfUsers() 是一个返回值且不会改变数据库状态的查询。相应的测试替身是一个存根。
SendGreetingsEmail() is a command whose side effect is sending an email. The test double that substitutes this command is a mock. On the other hand, GetNumberOfUsers() is a query that returns a value and doesn’t mutate the database state. The corresponding test double is a stub.
第 5.1 节介绍了什么是模拟。在解释模拟和测试脆弱性之间的联系的过程中,下一步是深入研究导致这种脆弱性的原因。
Section 5.1 showed what a mock is. The next step on the way to explaining the connection between mocks and test fragility is diving into what causes such fragility.
您可能还记得第 4 章的内容,测试脆弱性对应于优秀单元测试的第二个属性:抗重构性。(提醒一下,这四个属性分别是防止回归、抗重构性、快速反馈和可维护性。)抗重构性指标是最重要的,因为单元测试是否拥有这个指标大多是一个二元选择。因此,最好将此指标最大化,以使测试仍然停留在单元测试的范围内,而不会过渡到端到端测试的类别。后者虽然在抗重构方面最出色,但通常更难维护。
As you might remember from chapter 4, test fragility corresponds to the second attribute of a good unit test: resistance to refactoring. (As a reminder, the four attributes are protection against regressions, resistance to refactoring, fast feedback, and maintainability.) The metric of resistance to refactoring is the most important because whether a unit test possesses this metric is mostly a binary choice. Thus, it’s good to max out this metric to the extent that the test still remains in the realm of unit testing and doesn’t transition to the category of end-to-end testing. The latter, despite being the best at resistance to refactoring, is generally much harder to maintain.
在第 4 章中,您还看到了测试产生误报(从而无法抵抗重构)的主要原因是它们与代码的实现细节耦合。避免这种耦合的唯一方法是验证代码产生的最终结果(其可观察的行为)并尽可能将测试与实现细节分开。换句话说,测试必须关注是什么,而不是如何。那么,实现细节到底是什么?它与可观察的行为有何不同?
In chapter 4, you also saw that the main reason tests deliver false positives (and thus fail at resistance to refactoring) is because they couple to the code’s implementation details. The only way to avoid such coupling is to verify the end result the code produces (its observable behavior) and distance tests from implementation details as much as possible. In other words, tests must focus on the whats, not the hows. So, what exactly is an implementation detail, and how is it different from an observable behavior?
所有生产代码都可以按照两个维度进行分类:
All production code can be categorized along two dimensions:
这些维度中的类别不重叠。一个方法不能同时属于公共 API 和私有 API;它只能是其中之一。同样,代码要么是内部实现细节,要么是系统可观察行为的一部分,但不能同时是两者。
The categories in these dimensions don’t overlap. A method can’t belong to both a public and a private API; it’s either one or the other. Similarly, the code is either an internal implementation detail or part of the system’s observable behavior, but not both.
大多数编程语言都提供了一种简单的机制来区分代码库的公共和私有 API。例如,在 C# 中,你可以用private关键字标记类中的任何成员,该成员将对客户端代码隐藏,成为类的私有 API 的一部分。类也是如此:你可以使用private或internal关键字轻松地将它们设为私有。
Most programming languages provide a simple mechanism to differentiate between the code base’s public and private APIs. For example, in C#, you can mark any member in a class with the private keyword, and that member will be hidden from the client code, becoming part of the class’s private API. The same is true for classes: you can easily make them private by using the private or internal keyword.
可观察行为和内部实现细节之间的区别更加微妙。一段代码要想成为系统可观察行为的一部分,必须做到以下其中一件事:
The distinction between observable behavior and internal implementation details is more nuanced. For a piece of code to be part of the system’s observable behavior, it has to do one of the following things:
Any code that does neither of these two things is an implementation detail.
请注意,代码是否是可观察行为取决于其客户端是谁以及该客户端的目标是什么。为了成为可观察行为的一部分,代码需要与至少一个这样的目标有直接联系。客户端这个词可以指代不同的东西,具体取决于代码所在的位置。常见的例子是来自同一代码库的客户端代码、外部应用程序或用户界面。
Notice that whether the code is observable behavior depends on who its client is and what the goals of that client are. In order to be a part of observable behavior, the code needs to have an immediate connection to at least one such goal. The word client can refer to different things depending on where the code resides. The common examples are client code from the same code base, an external application, or the user interface.
理想情况下,系统的公共 API 界面应与其可观察的行为一致,并且所有实现细节都应隐藏在客户端看不到的地方。这样的系统具有设计良好的API(图 5.4)。
Ideally, the system’s public API surface should coincide with its observable behavior, and all its implementation details should be hidden from the eyes of the clients. Such a system has a well-designed API (figure 5.4).
然而,系统的公共 API 通常会超出其可观察的行为,并开始暴露实现细节。此类系统的实现细节会泄露到其公共 API 表面(图 5.5)。
Often, though, the system’s public API extends beyond its observable behavior and starts exposing implementation details. Such a system’s implementation details leak to its public API surface (figure 5.5).
让我们看一下实现细节泄露给公共 API 的代码示例。清单 5.5显示了一个User具有公共 API 的类,该类由两个成员组成:一个Name属性和一个NormalizeName()方法。该类还有一个不变量:用户的名称不得超过 50 个字符,否则应被截断。
Let’s take a look at examples of code whose implementation details leak to the public API. Listing 5.5 shows a User class with a public API that consists of two members: a Name property and a NormalizeName() method. The class also has an invariant: users’ names must not exceed 50 characters and should be truncated otherwise.
公共类用户
{
公共字符串名称 { 获取; 设置; }
公共字符串 NormalizeName(字符串名称)
{
字符串结果 = (名称 ??“”)。Trim();
如果 (结果.长度 > 50)
返回结果.Substring(0,50);
返回结果;
}
}
公共类用户控制器
{
public void RenameUser(int userId,string newName)
{
用户用户 = 从数据库获取用户 (用户 ID);
字符串 normalizedName = 用户.NormalizeName(新名称);
用户.姓名 = 规范化姓名;
保存用户到数据库(用户);
}
}public class User
{
public string Name { get; set; }
public string NormalizeName(string name)
{
string result = (name ?? "").Trim();
if (result.Length > 50)
return result.Substring(0, 50);
return result;
}
}
public class UserController
{
public void RenameUser(int userId, string newName)
{
User user = GetUserFromDatabase(userId);
string normalizedName = user.NormalizeName(newName);
user.Name = normalizedName;
SaveUserToDatabase(user);
}
}
UserControllerUser是客户端代码。它在其方法中使用该类RenameUser。您可能已经猜到了,该方法的目标是更改用户的名称。
UserController is client code. It uses the User class in its RenameUser method. The goal of this method, as you have probably guessed, is to change a user’s name.
那么,为什么 的 API 设计得不User好呢?再看看它的成员:Name属性和NormalizeName方法。它们都是公共的。因此,为了使类的 API 设计得好,这些成员应该是可观察行为的一部分。这反过来又要求他们做以下两件事之一(我在这里重复一下以方便理解):
So, why isn’t User’s API well-designed? Look at its members once again: the Name property and the NormalizeName method. Both of them are public. Therefore, in order for the class’s API to be well-designed, these members should be part of the observable behavior. This, in turn, requires them to do one of the following two things (which I’m repeating here for convenience):
只有Name属性满足此要求。它公开了一个 setter,这是一个允许UserController实现更改用户名称目标的操作。NormalizeName方法也是一个操作,但它与客户端的目标没有直接联系。UserController调用此方法的唯一原因是为了满足的不变量User。NormalizeName因此,这是一个泄漏到类的公共 API 的实现细节(图 5.6)。
Only the Name property meets this requirement. It exposes a setter, which is an operation that allows UserController to achieve its goal of changing a user’s name. The NormalizeName method is also an operation, but it doesn’t have an immediate connection to the client’s goal. The only reason UserController calls this method is to satisfy the invariant of User. NormalizeName is therefore an implementation detail that leaks to the class’s public API (figure 5.6).
为了修复这种情况并使类的 API 设计得更好,User需要将NormalizeName()其隐藏并在内部作为属性设置器的一部分进行调用,而不依赖于客户端代码来执行此操作。清单 5.6展示了这种方法。
To fix the situation and make the class’s API well-designed, User needs to hide NormalizeName() and call it internally as part of the property’s setter without relying on the client code to do so. Listing 5.6 shows this approach.
公共类用户
{
私有字符串_name;
公共字符串名称
{
获取 => _name;
设置 => _name = NormalizeName(value);
}
私有字符串 NormalizeName(字符串名称)
{
字符串结果 = (名称 ??“”)。Trim();
如果 (结果.长度 > 50)
返回结果.Substring(0,50);
返回结果;
}
}
公共类用户控制器
{
public void RenameUser(int userId,string newName)
{
用户用户 = 从数据库获取用户 (用户 ID);
用户.姓名 = 新姓名;
保存用户到数据库(用户);
}
}public class User
{
private string _name;
public string Name
{
get => _name;
set => _name = NormalizeName(value);
}
private string NormalizeName(string name)
{
string result = (name ?? "").Trim();
if (result.Length > 50)
return result.Substring(0, 50);
return result;
}
}
public class UserController
{
public void RenameUser(int userId, string newName)
{
User user = GetUserFromDatabase(userId);
user.Name = newName;
SaveUserToDatabase(user);
}
}
User清单 5.6中的 API设计良好:只有可观察的行为(Name属性)是公开的,而实现细节(NormalizeName方法)隐藏在私有 API 后面(图 5.7)。
User’s API in listing 5.6 is well-designed: only the observable behavior (the Name property) is made public, while the implementation details (the NormalizeName method) are hidden behind the private API (figure 5.7).
严格来说,Name的 getter 也应该设为私有,因为 不使用它UserController。但实际上,您几乎总是希望读回您所做的更改。因此,在实际项目中,肯定会有另一个用例需要通过Name的 getter 查看用户的当前名称。
Strictly speaking, Name’s getter should also be made private, because it’s not used by UserController. In reality, though, you almost always want to read back changes you make. Therefore, in a real project, there will certainly be another use case that requires seeing users’ current names via Name’s getter.
有一个很好的经验法则可以帮助您确定某个类是否泄露了其实现细节。如果客户端为了实现单个目标而必须在类上调用的操作数大于 1,则该类很可能泄露了实现细节。理想情况下,任何单个目标都应该通过单个操作来实现。例如,在清单 5.5UserController中,必须使用来自的两个操作User:
There’s a good rule of thumb that can help you determine whether a class leaks its implementation details. If the number of operations the client has to invoke on the class to achieve a single goal is greater than one, then that class is likely leaking implementation details. Ideally, any individual goal should be achieved with a single operation. In listing 5.5, for example, UserController has to use two operations from User:
字符串 normalizedName = 用户.NormalizeName(新名称); 用户.姓名 = 规范化姓名;
string normalizedName = user.NormalizeName(newName); user.Name = normalizedName;
重构之后,操作数减少为一个:
After the refactoring, the number of operations has been reduced to one:
用户.姓名 = 新姓名;
user.Name = newName;
根据我的经验,这条经验法则适用于涉及业务逻辑的绝大多数情况。不过,也可能有例外。不过,一定要检查代码违反此规则的每种情况,以防潜在的实现细节泄露。
In my experience, this rule of thumb holds true for the vast majority of cases where business logic is involved. There could very well be exceptions, though. Still, be sure to examine each situation where your code violates this rule for a potential leak of implementation details.
维护设计良好的 API 与封装概念有关。您可能还记得第 3 章的内容,封装是保护代码免受不一致(也称为不变量违规)影响的行为。不变量是应始终保持为真的条件。User上一个示例中的类有这样一个不变量:任何用户的名称都不能超过 50 个字符。
Maintaining a well-designed API relates to the notion of encapsulation. As you might recall from chapter 3, encapsulation is the act of protecting your code against inconsistencies, also known as invariant violations. An invariant is a condition that should be held true at all times. The User class from the previous example had one such invariant: no user could have a name that exceeded 50 characters.
暴露实现细节与违反不变性原则密切相关 —— 前者通常会导致后者。 的原始版本不仅User泄露了其实现细节,而且没有保持适当的封装。 它允许客户端绕过不变性原则并为用户分配一个新名称,而无需先对该名称进行规范化。
Exposing implementation details goes hand in hand with invariant violations—the former often leads to the latter. Not only did the original version of User leak its implementation details, but it also didn’t maintain proper encapsulation. It allowed the client to bypass the invariant and assign a new name to a user without normalizing that name first.
从长远来看,封装对于代码库的可维护性至关重要。原因在于复杂性。代码复杂性是软件开发中面临的最大挑战之一。代码库越复杂,处理起来就越困难,这反过来又会导致开发速度减慢和错误数量增加。
Encapsulation is crucial for code base maintainability in the long run. The reason why is complexity. Code complexity is one of the biggest challenges you’ll face in software development. The more complex the code base becomes, the harder it is to work with, which, in turn, results in slowing down development speed and increasing the number of bugs.
如果没有封装,您就没有切实可行的方法来应对不断增加的代码复杂性。当代码的 API 无法指导您使用该代码做什么和不允许做什么时,您必须记住大量信息,以确保不会因新的代码更改而引入不一致。这给编程过程带来了额外的精神负担。尽可能地减轻自己的负担。您不能相信自己总是做正确的事情 - 因此,要消除做错事的可能性。最好的方法是保持适当的封装,以便您的代码库甚至不会为您提供做错任何事的选项。封装最终与单元测试具有相同的目标:它使您的软件项目能够可持续发展。
Without encapsulation, you have no practical way to cope with ever-increasing code complexity. When the code’s API doesn’t guide you through what is and what isn’t allowed to be done with that code, you have to keep a lot of information in mind to make sure you don’t introduce inconsistencies with new code changes. This brings an additional mental burden to the process of programming. Remove as much of that burden from yourself as possible. You cannot trust yourself to do the right thing all the time—so, eliminate the very possibility of doing the wrong thing. The best way to do so is to maintain proper encapsulation so that your code base doesn’t even provide an option for you to do anything incorrectly. Encapsulation ultimately serves the same goal as unit testing: it enables sustainable growth of your software project.
有一个类似的原则:告诉而不问。它是由 Martin Fowler ( https://martinfowler.com/bliki/TellDontAsk.html )创造的,代表将数据与对该数据进行操作的函数捆绑在一起。您可以将此原则视为封装实践的必然结果。代码封装是一个目标,而将数据和函数捆绑在一起以及隐藏实现细节是实现该目标的手段:
There’s a similar principle: tell-don’t-ask. It was coined by Martin Fowler (https://martinfowler.com/bliki/TellDontAsk.html) and stands for bundling data with the functions that operate on that data. You can view this principle as a corollary to the practice of encapsulation. Code encapsulation is a goal, whereas bundling data and functions together, as well as hiding implementation details, are the means to achieve that goal:
清单 5.5中的示例演示了一个操作(NormalizeName方法),它是泄漏给公共 API 的实现细节。我们还来看一个有状态的示例。下面的清单包含您在第 4 章MessageRenderer中看到的类。它使用一组子渲染器来生成包含标题、正文和页脚的消息的 HTML 表示。
The example shown in listing 5.5 demonstrated an operation (the NormalizeName method) that was an implementation detail leaking to the public API. Let’s also look at an example with state. The following listing contains the MessageRenderer class you saw in chapter 4. It uses a collection of sub-renderers to generate an HTML representation of a message containing a header, a body, and a footer.
公共类 MessageRenderer:IRenderer
{
公共 IReadOnlyList<IRenderer> SubRenderers { 获取; }
公共消息渲染器()
{
SubRenderers = new List<IRenderer>
{
新的 HeaderRenderer(),
新的BodyRenderer(),
新的 FooterRenderer()
};
}
公共字符串渲染(消息消息)
{
返回 SubRenderers
.选择(x => x.渲染(消息))
.聚合(“”,(str1,str2)=> str1 + str2);
}
}public class MessageRenderer : IRenderer
{
public IReadOnlyList<IRenderer> SubRenderers { get; }
public MessageRenderer()
{
SubRenderers = new List<IRenderer>
{
new HeaderRenderer(),
new BodyRenderer(),
new FooterRenderer()
};
}
public string Render(Message message)
{
return SubRenderers
.Select(x => x.Render(message))
.Aggregate("", (str1, str2) => str1 + str2);
}
}
子渲染器集合是公开的。但它是可观察行为的一部分吗?假设客户端的目标是渲染 HTML 消息,答案是否定的。这样的客户端需要的唯一类成员是Render方法本身。因此SubRenderers也是一个泄漏的实现细节。
The sub-renderers collection is public. But is it part of observable behavior? Assuming that the client’s goal is to render an HTML message, the answer is no. The only class member such a client would need is the Render method itself. Thus SubRenderers is also a leaking implementation detail.
我再次提起这个例子是有原因的。您可能还记得,我用它来说明一个脆弱的测试。该测试之所以脆弱,正是因为它与这个实现细节紧密相关——它检查集合的组成。通过将测试重新定位到Render方法上,可以解决脆弱性问题。新版本的测试验证了结果消息——客户端代码唯一关心的输出,即可观察的行为。
I bring up this example again for a reason. As you may remember, I used it to illustrate a brittle test. That test was brittle precisely because it was tied to this implementation detail—it checked to see the collection’s composition. The brittleness was fixed by re-targeting the test at the Render method. The new version of the test verified the resulting message—the only output the client code cared about, the observable behavior.
如您所见,良好的单元测试与精心设计的 API 之间存在内在联系。通过将所有实现细节设为私有,您的测试就别无选择,只能验证代码的可观察行为,这会自动提高其对重构的抵抗力。
As you can see, there’s an intrinsic connection between good unit tests and a well-designed API. By making all implementation details private, you leave your tests no choice other than to verify the code’s observable behavior, which automatically improves their resistance to refactoring.
使 API 设计良好会自动改进单元测试。
Making the API well-designed automatically improves unit tests.
另一条准则源自精心设计的 API 的定义:您应该公开绝对最少数量的操作和状态。只有直接帮助客户实现其目标的代码才应公开。其他一切都是实现细节,因此必须隐藏在私有 API 后面。
Another guideline flows from the definition of a well-designed API: you should expose the absolute minimum number of operations and state. Only code that directly helps clients achieve their goals should be made public. Everything else is implementation details and thus must be hidden behind the private API.
请注意,不存在可观察行为泄漏的问题,该问题与实现细节泄漏的问题对称。虽然您可以公开实现细节(客户端不应使用的方法或类),但您无法隐藏可观察行为。这样的方法或类将不再与客户端目标有直接联系,因为客户端将无法再直接使用它。因此,根据定义,此代码将不再是可观察行为的一部分。表 5.1总结了所有内容。
Note that there’s no such problem as leaking observable behavior, which would be symmetric to the problem of leaking implementation details. While you can expose an implementation detail (a method or a class that is not supposed to be used by the client), you can’t hide an observable behavior. Such a method or class would no longer have an immediate connection to the client goals, because the client wouldn’t be able to directly use it anymore. Thus, by definition, this code would cease to be part of observable behavior. Table 5.1 sums it all up.
|
可观察的行为 Observable behavior |
实施细节 Implementation detail |
|
|---|---|---|
| 民众 | 好的 | 坏的 |
| 私人的 | 不适用 | 好的 |
前面几节定义了模拟,并展示了可观察行为和实现细节之间的区别。在本节中,您将了解六边形架构、内部和外部通信之间的区别,以及(最后!)模拟和测试脆弱性之间的关系。
The previous sections defined a mock and showed the difference between observable behavior and an implementation detail. In this section, you will learn about hexagonal architecture, the difference between internal and external communications, and (finally!) the relationship between mocks and test fragility.
典型的应用程序由两层组成,即域层和应用服务层,如图 5.8所示。域层位于图的中间,因为它是应用程序的核心部分。它包含业务逻辑:应用程序构建的基本功能。域层及其业务逻辑使此应用程序与其他应用程序区分开来,并为组织提供竞争优势。
A typical application consists of two layers, domain and application services, as shown in figure 5.8. The domain layer resides in the middle of the diagram because it’s the central part of your application. It contains the business logic: the essential functionality your application is built for. The domain layer and its business logic differentiate this application from others and provide a competitive advantage for the organization.
应用服务层位于领域层之上,负责协调该层与外部世界之间的通信。例如,如果您的应用程序是 RESTful API,则对该 API 的所有请求都会首先到达应用服务层。然后,该层会协调领域类和进程外依赖项之间的工作。以下是应用服务进行此类协调的一个示例。它执行以下操作:
The application services layer sits on top of the domain layer and orchestrates communication between that layer and the external world. For example, if your application is a RESTful API, all requests to this API hit the application services layer first. This layer then coordinates the work between domain classes and out-of-process dependencies. Here’s an example of such coordination for the application service. It does the following:
应用服务层和领域层的组合形成一个六边形,它本身代表您的应用程序。它可以与其他应用程序交互,这些应用程序用自己的六边形表示(见图 5.9)。这些其他应用程序可能是 SMTP 服务、第三方系统、消息总线等。一组相互作用的六边形构成了六边形架构。
The combination of the application services layer and the domain layer forms a hexagon, which itself represents your application. It can interact with other applications, which are represented with their own hexagons (see figure 5.9). These other applications could be an SMTP service, a third-party system, a message bus, and so on. A set of interacting hexagons makes up a hexagonal architecture.
六边形架构这一术语由 Alistair Cockburn 提出。其目的是强调三个重要准则:
The term hexagonal architecture was introduced by Alistair Cockburn. Its purpose is to emphasize three important guidelines:
应用程序的每一层都表现出可观察的行为,并包含自己的一组实现细节。例如,域层的可观察行为是该层的操作和状态的总和,可帮助应用服务层实现其至少一个目标。精心设计的 API 的原则具有分形性质:它们同样适用于整个层或单个类。
Each layer of your application exhibits observable behavior and contains its own set of implementation details. For example, observable behavior of the domain layer is the sum of this layer’s operations and state that helps the application service layer achieve at least one of its goals. The principles of a well-designed API have a fractal nature: they apply equally to as much as a whole layer or as little as a single class.
当你精心设计每一层的 API(即隐藏其实现细节)时,你的测试也会开始具有分形结构;它们验证有助于实现相同目标但不同级别的行为。覆盖应用服务的测试检查此服务如何实现外部客户端提出的总体、粗粒度目标。同时,使用域类的测试验证作为更大目标一部分的子目标(图 5.10)。
When you make each layer’s API well-designed (that is, hide its implementation details), your tests also start to have a fractal structure; they verify behavior that helps achieve the same goals but at different levels. A test covering an application service checks to see how this service attains an overarching, coarse-grained goal posed by the external client. At the same time, a test working with a domain class verifies a subgoal that is part of that greater goal (figure 5.10).
您可能还记得我在前面的章节中提到过,您应该能够将任何测试追溯到特定的业务需求。每个测试都应该讲述一个对领域专家有意义的故事,如果没有,则强烈表明测试与实现细节耦合,因此很脆弱。我希望现在您可以明白为什么。
You might remember from previous chapters how I mentioned that you should be able to trace any test back to a particular business requirement. Each test should tell a story that is meaningful to a domain expert, and if it doesn’t, that’s a strong indication that the test couples to implementation details and therefore is brittle. I hope now you can see why.
可观察的行为从外层流向中心。外部客户提出的总体目标被转化为个人实现的子目标域类。因此,域层中的每一段可观察行为都保留了与特定业务用例的联系。您可以从最内层(域)向外递归地跟踪此联系,直到应用服务层,然后跟踪外部客户端的需求。这种可追溯性源于可观察行为的定义。要使一段代码成为可观察行为的一部分,它需要帮助客户端实现其目标之一。对于域类,客户端是应用服务;对于应用服务,它是外部客户端本身。
Observable behavior flows inward from outer layers to the center. The overarching goal posed by the external client gets translated into subgoals achieved by individual domain classes. Each piece of observable behavior in the domain layer therefore preserves the connection to a particular business use case. You can trace this connection recursively from the innermost (domain) layer outward to the application services layer and then to the needs of the external client. This traceability follows from the definition of observable behavior. For a piece of code to be part of observable behavior, it needs to help the client achieve one of its goals. For a domain class, the client is an application service; for the application service, it’s the external client itself.
验证具有精心设计的 API 的代码库的测试也与业务需求有关,因为这些测试仅与可观察的行为相关。一个很好的例子是清单 5.6User中的和UserController类(为了方便起见,我在这里重复了代码)。
Tests that verify a code base with a well-designed API also have a connection to business requirements because those tests tie to the observable behavior only. A good example is the User and UserController classes from listing 5.6 (I’m repeating the code here for convenience).
公共类用户
{
私有字符串_name;
公共字符串名称
{
获取 => _name;
设置 => _name = NormalizeName(value);
}
私有字符串 NormalizeName(字符串名称)
{
/* 将名称缩减为 50 个字符 */
}
}
公共类用户控制器
{
public void RenameUser(int userId,string newName)
{
用户用户 = 从数据库获取用户 (用户 ID);
用户.姓名 = 新姓名;
保存用户到数据库(用户);
}
}public class User
{
private string _name;
public string Name
{
get => _name;
set => _name = NormalizeName(value);
}
private string NormalizeName(string name)
{
/* Trim name down to 50 characters */
}
}
public class UserController
{
public void RenameUser(int userId, string newName)
{
User user = GetUserFromDatabase(userId);
user.Name = newName;
SaveUserToDatabase(user);
}
}
UserController在此示例中,是应用服务。假设外部客户端没有规范化用户名的特定目标,并且所有名称都仅由于应用程序本身的限制而规范化,则类NormalizeName中的方法User无法追溯到客户端的需求。因此,它是一个实现细节,应该设为私有(我们在本章前面已经这样做了)。此外,测试不应该直接检查此方法。它们应该仅将其作为类的可观察行为的一部分进行验证 -Name在此示例中为属性的设置器。
UserController in this example is an application service. Assuming that the external client doesn’t have a specific goal of normalizing user names, and all names are normalized solely due to restrictions from the application itself, the NormalizeName method in the User class can’t be traced to the client’s needs. Therefore, it’s an implementation detail and should be made private (we already did that earlier in this chapter). Moreover, tests shouldn’t check this method directly. They should verify it only as part of the class’s observable behavior—the Name property’s setter in this example.
始终将代码库的公共 API 追溯到业务需求这一指导原则适用于绝大多数领域类和应用服务,但较少实用程序和基础设施代码也是如此。此类代码解决的个别问题通常太低级和太细粒度,无法追溯到特定的业务用例。
This guideline of always tracing the code base’s public API to business requirements applies to the vast majority of domain classes and application services but less so to utility and infrastructure code. The individual problems such code solves are often too low-level and fine-grained and can’t be traced to a specific business use case.
典型应用程序中有两种类型的通信:系统内通信和系统间通信。系统内通信是应用程序内部类之间的通信。系统间通信是指您的应用程序与其他应用程序对话(图 5.11)。
There are two types of communications in a typical application: intra-system and inter-system. Intra-system communications are communications between classes inside your application. Inter-system communications are when your application talks to other applications (figure 5.11).
系统内通信是实现细节;系统间通信则不是。
Intra-system communications are implementation details; inter-system communications are not.
系统内通信是实现细节,因为您的领域类为执行操作而进行的协作不是其可观察行为的一部分。这些协作与客户端的目标没有直接联系。因此,与此类协作的耦合会导致测试脆弱。
Intra-system communications are implementation details because the collaborations your domain classes go through in order to perform an operation are not part of their observable behavior. These collaborations don’t have an immediate connection to the client’s goal. Thus, coupling to such collaborations leads to fragile tests.
系统间通信则是另一回事。与应用程序内部类之间的协作不同,系统与外部世界的对话方式形成了整个系统的可观察行为。这是应用程序必须始终遵守的契约的一部分(图 5.12)。
Inter-system communications are a different matter. Unlike collaborations between classes inside your application, the way your system talks to the external world forms the observable behavior of that system as a whole. It’s part of the contract your application must hold at all times (figure 5.12).
系统间通信的这一属性源于独立应用程序共同演进的方式。这种演进的主要原则之一是保持向后兼容性。无论您在系统内部执行何种重构,它用于与外部应用程序通信的通信模式都应始终保持不变,以便外部应用程序能够理解它。例如,您的应用程序在总线上发出的消息应保留其结构,对 SMTP 服务发出的调用应具有相同数量和类型的参数,等等。
This attribute of inter-system communications stems from the way separate applications evolve together. One of the main principles of such an evolution is maintaining backward compatibility. Regardless of the refactorings you perform inside your system, the communication pattern it uses to talk to external applications should always stay in place, so that external applications can understand it. For example, messages your application emits on a bus should preserve their structure, the calls issued to an SMTP service should have the same number and type of parameters, and so on.
在验证系统与外部应用程序之间的通信模式时,使用模拟非常有用。相反,使用模拟来验证系统内部类之间的通信会导致测试与实现细节耦合,因此无法达到抗重构指标。
The use of mocks is beneficial when verifying the communication pattern between your system and external applications. Conversely, using mocks to verify communications between classes inside your system results in tests that couple to implementation details and therefore fall short of the resistance-to-refactoring metric.
为了说明系统内和系统间通信之间的区别,我将使用第 2 章和本章前面使用的Customer和类来扩展示例。想象一下以下业务用例:Store
To illustrate the difference between intra-system and inter-system communications, I’ll expand on the example with the Customer and Store classes that I used in chapter 2 and earlier in this chapter. Imagine the following business use case:
我们还假设该应用程序是一个没有用户界面的 API。
Let’s also assume that the application is an API with no user interface.
在下面的清单中,类是一个应用程序服务,它协调域类( 、、)和外部应用程序(,它是 SMTP 服务的代理)之间CustomerController的工作。CustomerProductStoreEmailGateway
In the following listing, the CustomerController class is an application service that orchestrates the work between domain classes (Customer, Product, Store) and the external application (EmailGateway, which is a proxy to an SMTP service).
公共类客户控制器
{
公共布尔购买(int customerId,int productId,int 数量)
{
客户客户 = _customerRepository.GetById(customerId);
产品product = _productRepository.GetById(productId);
bool isSuccess = 客户.购买(
_mainStore,产品,数量);
如果 (成功)
{
_emailGateway.发送收据(
客户.电子邮件,产品.名称,数量);
}
返回是否成功;
}
}public class CustomerController
{
public bool Purchase(int customerId, int productId, int quantity)
{
Customer customer = _customerRepository.GetById(customerId);
Product product = _productRepository.GetById(productId);
bool isSuccess = customer.Purchase(
_mainStore, product, quantity);
if (isSuccess)
{
_emailGateway.SendReceipt(
customer.Email, product.Name, quantity);
}
return isSuccess;
}
}
为简洁起见,省略了输入参数的验证。在该Purchase方法中,客户检查商店中是否有足够的库存,如果是,则减少产品数量。
Validation of input parameters is omitted for brevity. In the Purchase method, the customer checks to see if there’s enough inventory in the store and, if so, decreases the product amount.
购买行为是具有系统内和系统间通信的业务用例。系统间通信是CustomerController应用服务与两个外部系统之间的通信:第三方应用程序(也是启动用例的客户端)和电子邮件网关。系统内通信是Customer与Store领域类之间的通信(图 5.13)。
The act of making a purchase is a business use case with both intra-system and inter-system communications. The inter-system communications are those between the CustomerController application service and the two external systems: the third-party application (which is also the client initiating the use case) and the email gateway. The intra-system communication is between the Customer and the Store domain classes (figure 5.13).
在这个例子中,对 SMTP 服务的调用是一个对外部世界可见的副作用,从而形成了整个应用程序的可观察行为。它还与客户的目标有直接联系。应用程序的客户是第三方系统。该系统的目标是进行购买,并期望客户收到确认电子邮件作为成功结果的一部分。
In this example, the call to the SMTP service is a side effect that is visible to the external world and thus forms the observable behavior of the application as a whole. It also has a direct connection to the client’s goals. The client of the application is the third-party system. This system’s goal is to make a purchase, and it expects the customer to receive a confirmation email as part of the successful outcome.
调用 SMTP 服务是进行模拟的正当理由。它不会导致测试脆弱性,因为您想确保这种类型的通信即使在重构后也能保持不变。使用模拟可以帮助您做到这一点。
The call to the SMTP service is a legitimate reason to do mocking. It doesn’t lead to test fragility because you want to make sure this type of communication stays in place even after refactoring. The use of mocks helps you do exactly that.
下一个清单展示了合法使用模拟的一个例子。
The next listing shows an example of a legitimate use of mocks.
[事实]
公共无效成功购买()
{
var mock = new Mock<IEmailGateway>();
var sut = new CustomerController(mock.Object);
bool isSuccess = sut.Purchase(
客户ID:1,产品ID:2,数量:5);
断言.True(是否成功);
模拟.验证( 1
x => x.SendReceipt( 1
“customer@email.com”, “洗发水”,5), 1
Times.Once); 1
}[Fact]
public void Successful_purchase()
{
var mock = new Mock<IEmailGateway>();
var sut = new CustomerController(mock.Object);
bool isSuccess = sut.Purchase(
customerId: 1, productId: 2, quantity: 5);
Assert.True(isSuccess);
mock.Verify( 1
x => x.SendReceipt( 1
"customer@email.com", "Shampoo", 5), 1
Times.Once); 1
}
请注意,该isSuccess标志也可由外部客户端观察,也需要验证。不过,此标志不需要模拟;简单的值比较就足够了。
Note that the isSuccess flag is also observable by the external client and also needs verification. This flag doesn’t need mocking, though; a simple value comparison is enough.
Customer现在让我们看一个模拟和之间通信的测试Store。
Let’s now look at a test that mocks the communication between Customer and Store.
[事实]
public void Purchase_succeeds_when_enough_inventory()
{
var storeMock = new Mock<IStore>();
存储模拟
.设置(x => x.HasEnoughInventory(产品.洗发水,5))
.返回(true);
var 客户 = 新客户();
bool 成功 = 客户.购买(
storeMock.Object,产品.洗发水,5);
断言.True(成功);
storeMock.验证(
x => x.删除库存(产品.洗发水,5),
次.一次);
}[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
var storeMock = new Mock<IStore>();
storeMock
.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
.Returns(true);
var customer = new Customer();
bool success = customer.Purchase(
storeMock.Object, Product.Shampoo, 5);
Assert.True(success);
storeMock.Verify(
x => x.RemoveInventory(Product.Shampoo, 5),
Times.Once);
}
CustomerController与 和 SMTP 服务之间的通信不同,从到 的RemoveInventory()方法调用不跨越应用程序边界:调用者和接收者都驻留在应用程序内部。此外,此方法既不是操作,也不是帮助客户端实现其目标的状态。这两个域类的客户端的目标是进行购买。与此目标有直接联系的仅有的两个成员是和。该方法启动购买,并在购买完成后显示系统的状态。方法调用是实现客户端目标的中间步骤 - 一个实现细节。CustomerStoreCustomerControllercustomer.Purchase()store.GetInventory()Purchase()GetInventory()RemoveInventory()
Unlike the communication between CustomerController and the SMTP service, the RemoveInventory() method call from Customer to Store doesn’t cross the application boundary: both the caller and the recipient reside inside the application. Also, this method is neither an operation nor a state that helps the client achieve its goals. The client of these two domain classes is CustomerController with the goal of making a purchase. The only two members that have an immediate connection to this goal are customer.Purchase() and store.GetInventory(). The Purchase() method initiates the purchase, and GetInventory() shows the state of the system after the purchase is completed. The RemoveInventory() method call is an intermediate step on the way to the client’s goal—an implementation detail.
与第 2 章(表 2.1 )的提醒一样,表 5.2总结了古典学派和伦敦学派单元测试之间的差异。
As a reminder from chapter 2 (table 2.1), table 5.2 sums up the differences between the classical and London schools of unit testing.
|
隔离 Isolation of |
一个单位是 A unit is |
使用测试替身 Uses test doubles for |
|
|---|---|---|---|
| 伦敦学派 | 单位 | 一个类 | 除了不可变的依赖关系之外的所有依赖关系 |
| 古典学派 | 单元测试 | 一个类或一组类 | 共享依赖项 |
在第 2 章中,我提到我更喜欢经典的单元测试流派,而不是伦敦流派。我希望现在你能明白为什么。伦敦流派鼓励对除不可变依赖项之外的所有依赖项使用模拟,并且不区分系统内和系统间通信。因此,测试检查类之间的通信就像检查应用程序与外部系统之间的通信一样多。
In chapter 2, I mentioned that I prefer the classical school of unit testing over the London school. I hope now you can see why. The London school encourages the use of mocks for all but immutable dependencies and doesn’t differentiate between intra-system and inter-system communications. As a result, tests check communications between classes just as much as they check communications between your application and external systems.
这种对 mock 的滥用就是为什么遵循伦敦学派的测试经常与实现细节耦合,因此缺乏对重构的抵抗力。您可能还记得第 4 章的内容,对重构的抵抗力指标(与其他三个指标不同)主要是二元选择:测试要么具有对重构的抵抗力,要么没有。在这个指标上妥协会使测试几乎毫无价值。
This indiscriminate use of mocks is why following the London school often results in tests that couple to implementation details and thus lack resistance to refactoring. As you may remember from chapter 4, the metric of resistance to refactoring (unlike the other three) is mostly a binary choice: a test either has resistance to refactoring or it doesn’t. Compromising on this metric renders the test nearly worthless.
古典学派在这个问题上做得更好,因为它主张只替换测试之间共享的依赖项,这几乎总是转化为进程外依赖项,例如 SMTP 服务、消息总线等。但古典学派在处理系统间通信方面也不理想。这个学派还鼓励过度使用模拟,尽管不像伦敦学派那么多。
The classical school is much better at this issue because it advocates for substituting only dependencies that are shared between tests, which almost always translates into out-of-process dependencies such as an SMTP service, a message bus, and so on. But the classical school is not ideal in its treatment of inter-system communications, either. This school also encourages excessive use of mocks, albeit not as much as the London school.
在我们讨论进程外依赖和模拟之前,让我快速复习一下依赖的类型(更多详细信息请参阅第 2 章):
Before we discuss out-of-process dependencies and mocking, let me give you a quick refresher on types of dependencies (refer to chapter 2 for more details):
传统学派建议避免共享依赖关系,因为它们为测试提供了干扰彼此执行上下文的手段,从而阻止这些测试并行运行。测试并行、连续和以任何顺序运行的能力称为测试隔离。
The classical school recommends avoiding shared dependencies because they provide the means for tests to interfere with each other’s execution context and thus prevent those tests from running in parallel. The ability for tests to run in parallel, sequentially, and in any order is called test isolation.
如果共享依赖项不是进程外的,那么通过在每次测试运行时提供它的新实例,很容易避免在测试中重用它。如果共享依赖项是进程外的,测试就会变得更加复杂。您无法在每次测试执行之前实例化新数据库或配置新的消息总线;这会大大减慢测试套件的速度。通常的方法是使用测试替身(模拟和存根)替换此类依赖项。
If a shared dependency is not out-of-process, then it’s easy to avoid reusing it in tests by providing a new instance of it on each test run. In cases where the shared dependency is out-of-process, testing becomes more complicated. You can’t instantiate a new database or provision a new message bus before each test execution; that would drastically slow down the test suite. The usual approach is to replace such dependencies with test doubles—mocks and stubs.
不过,并非所有进程外依赖项都应被模拟。如果进程外依赖项只能通过应用程序访问,则与此类依赖项的通信不属于系统可观察行为的一部分。实际上,无法从外部观察到的进程外依赖项充当应用程序的一部分(图 5.14)。
Not all out-of-process dependencies should be mocked out, though. If an out-of-process dependency is only accessible through your application, then communications with such a dependency are not part of your system’s observable behavior. An out-of-process dependency that can’t be observed externally, in effect, acts as part of your application (figure 5.14).
请记住,始终保留应用程序与外部系统之间的通信模式的要求源于保持向后兼容性的必要性。您必须保持应用程序与外部系统通信的方式系统。这是因为您无法与应用程序同时更改这些外部系统;它们可能遵循不同的部署周期,或者您可能根本无法控制它们。
Remember, the requirement to always preserve the communication pattern between your application and external systems stems from the necessity to maintain backward compatibility. You have to maintain the way your application talks to external systems. That’s because you can’t change those external systems simultaneously with your application; they may follow a different deployment cycle, or you might simply not have control over them.
但是,当您的应用程序充当外部系统的代理,并且没有客户端可以直接访问它时,向后兼容性要求就消失了。现在您可以将应用程序与此外部系统一起部署,并且不会影响客户端。与此类系统的通信模式成为实现细节。
But when your application acts as a proxy to an external system, and no client can access it directly, the backward-compatibility requirement vanishes. Now you can deploy your application together with this external system, and it won’t affect the clients. The communication pattern with such a system becomes an implementation detail.
一个很好的例子是应用程序数据库:仅由您的应用程序使用的数据库。没有外部系统可以访问此数据库。因此,您可以以任何您喜欢的方式修改系统和应用程序数据库之间的通信模式,只要它不会破坏现有功能即可。由于该数据库完全隐藏在客户端的视线之外,您甚至可以用完全不同的存储机制替换它,没有人会注意到。
A good example here is an application database: a database that is used only by your application. No external system has access to this database. Therefore, you can modify the communication pattern between your system and the application database in any way you like, as long as it doesn’t break existing functionality. Because that database is completely hidden from the eyes of the clients, you can even replace it with an entirely different storage mechanism, and no one will notice.
使用模拟来处理您完全控制的进程外依赖项也会导致测试变得脆弱。您不希望每次拆分数据库中的表或修改存储过程中某个参数的类型时测试都变成红色。数据库和您的应用程序必须被视为一个系统。
The use of mocks for out-of-process dependencies that you have a full control over also leads to brittle tests. You don’t want your tests to turn red every time you split a table in the database or modify the type of one of the parameters in a stored procedure. The database and your application must be treated as one system.
这显然带来了一个问题。如何在不影响反馈速度(优秀单元测试的第三个属性)的情况下测试具有这种依赖关系的工作?您将在接下来的两章中深入讨论这个主题。
This obviously poses an issue. How would you test the work with such a dependency without compromising the feedback speed, the third attribute of a good unit test? You’ll see this subject covered in depth in the following two chapters.
模拟通常被认为可以验证行为。但在大多数情况下,模拟并非如此。每个单独的类与相邻类交互以实现某些目标的方式与可观察的行为无关;这是一个实现细节。
Mocks are often said to verify behavior. In the vast majority of cases, they don’t. The way each individual class interacts with neighboring classes in order to achieve some goal has nothing to do with observable behavior; it’s an implementation detail.
验证类之间的通信类似于通过测量大脑神经元相互传递的信号来推断人的行为。这种细节程度太过细致。重要的是可以追溯到客户目标的行为。客户不关心当他们要求你帮助时你大脑中的哪些神经元亮了起来。唯一重要的是帮助本身——当然是由你以可靠和专业的方式提供的。只有当模拟验证跨越应用程序边界的交互并且只有当这些交互的副作用对外部世界可见时,模拟才与行为有关。
Verifying communications between classes is akin to trying to derive a person’s behavior by measuring the signals that neurons in the brain pass among each other. Such a level of detail is too granular. What matters is the behavior that can be traced back to the client goals. The client doesn’t care what neurons in your brain light up when they ask you to help. The only thing that matters is the help itself—provided by you in a reliable and professional fashion, of course. Mocks have something to do with behavior only when they verify interactions that cross the application boundary and only when the side effects of those interactions are visible to the external world.
本章涵盖
This chapter covers
第 4 章介绍了优秀单元测试的四个属性:防回归、抗重构、快速反馈和可维护性。这些属性构成了一个参考框架,您可以使用它来分析特定的测试和单元测试方法。我们在第 5 章中分析了其中一种方法:使用模拟。
Chapter 4 introduced the four attributes of a good unit test: protection against regressions, resistance to refactoring, fast feedback, and maintainability. These attributes form a frame of reference that you can use to analyze specific tests and unit testing approaches. We analyzed one such approach in chapter 5: the use of mocks.
在本章中,我将同样的参考框架应用于单元测试样式主题。此类样式有三种:基于输出的测试、基于状态的测试和基于通信的测试。在这三种样式中,基于输出的样式产生的测试质量最高,基于状态的测试是第二好的选择,而基于通信的测试仅应偶尔使用。
In this chapter, I apply the same frame of reference to the topic of unit testing styles. There are three such styles: output-based, state-based, and communication-based testing. Among the three, the output-based style produces tests of the highest quality, state-based testing is the second-best choice, and communication-based testing should be used only occasionally.
不幸的是,您不能在任何地方使用基于输出的测试风格。它只适用于以纯函数式编写的代码。但别担心;有一些技术可以帮助您将更多的测试转换为基于输出的风格。为此,您需要使用函数式编程原则将底层代码重构为函数式架构。
Unfortunately, you can’t use the output-based testing style everywhere. It’s only applicable to code written in a purely functional way. But don’t worry; there are techniques that can help you transform more of your tests into the output-based style. For that, you’ll need to use functional programming principles to restructure the underlying code toward a functional architecture.
请注意,本章不会深入探讨函数式编程这一主题。不过,我希望在本章结束时,您能够直观地了解函数式编程与基于输出的测试之间的关系。您还将学习如何使用基于输出的样式编写更多测试,以及函数式编程和函数式架构的局限性。
Note that this chapter doesn’t provide a deep dive into the topic of functional programming. Still, by the end of this chapter, I hope you’ll have an intuitive understanding of how functional programming relates to output-based testing. You’ll also learn how to write more of your tests using the output-based style, as well as the limitations of functional programming and functional architecture.
正如我在章节介绍中提到的,单元测试有三种风格:
As I mentioned in the chapter introduction, there are three styles of unit testing:
您可以在单个测试中同时使用一种、两种甚至所有三种风格。本节通过定义(并附有示例)这三种单元测试风格为整章奠定了基础。您将在下一节中看到它们之间的比较。
You can employ one, two, or even all three styles together in a single test. This section lays the foundation for the whole chapter by defining (with examples) those three styles of unit testing. You’ll see how they score against each other in the section after that.
第一种单元测试风格是基于输出的风格,即向被测系统 (SUT) 提供输入并检查其产生的输出(图 6.1)。这种单元测试风格仅适用于不会改变全局或内部状态的代码,因此唯一需要验证的组件是其返回值。
The first style of unit testing is the output-based style, where you feed an input to the system under test (SUT) and check the output it produces (figure 6.1). This style of unit testing is only applicable to code that doesn’t change a global or internal state, so the only component to verify is its return value.
以下清单显示了此类代码的示例以及覆盖它的测试。该类PriceEngine接受产品数组并计算折扣。
The following listing shows an example of such code and a test covering it. The PriceEngine class accepts an array of products and calculates a discount.
公开课 PriceEngine
{
公共小数计算折扣(参数产品[] 产品)
{
小数折扣 = 产品.长度 * 0.01m;
返回 Math.Min(折扣,0.2m);
}
}
[事实]
public void Discount_of_two_products()
{
var product1 = new Product("手洗");
var product2 = new Product("洗发水");
var sut = new PriceEngine();
十进制折扣 = sut.CalculateDiscount(产品1,产品2);
断言.等于(0.02m,折扣);
}public class PriceEngine
{
public decimal CalculateDiscount(params Product[] products)
{
decimal discount = products.Length * 0.01m;
return Math.Min(discount, 0.2m);
}
}
[Fact]
public void Discount_of_two_products()
{
var product1 = new Product("Hand wash");
var product2 = new Product("Shampoo");
var sut = new PriceEngine();
decimal discount = sut.CalculateDiscount(product1, product2);
Assert.Equal(0.02m, discount);
}
PriceEngine将产品数量乘以 1%,并将结果限制为 20%。此类没有其他内容。它不会将产品添加到任何内部集合中,也不会将它们保存在数据库中。该CalculateDiscount()方法的唯一结果是它返回的折扣:输出值(图 6.2)。
PriceEngine multiplies the number of products by 1% and caps the result at 20%. There’s nothing else to this class. It doesn’t add the products to any internal collection, nor does it persist them in a database. The only outcome of the CalculateDiscount() method is the discount it returns: the output value (figure 6.2).
基于输出的单元测试风格也称为函数式。这个名字源于函数式编程,这是一种强调无副作用代码的编程方法。我们将在本章后面进一步讨论函数式编程和函数式架构。
The output-based style of unit testing is also known as functional. This name takes root in functional programming, a method of programming that emphasizes a preference for side-effect-free code. We’ll talk more about functional programming and functional architecture later in this chapter.
基于状态的测试方式是在操作完成后验证系统的状态(图 6.3 )。这种测试方式中的“状态”一词可以指 SUT 本身的状态、其协作者之一的状态或进程外依赖项(如数据库或文件系统)的状态。
The state-based style is about verifying the state of the system after an operation is complete (figure 6.3). The term state in this style of testing can refer to the state of the SUT itself, of one of its collaborators, or of an out-of-process dependency, such as the database or the filesystem.
以下是基于状态的测试示例。该类Order允许客户端添加新产品。
Here’s an example of state-based testing. The Order class allows the client to add a new product.
公开课秩序
{
私有只读列表<产品> _products = 新列表<产品>();
公共 IReadOnlyList<Product> Products => _products.ToList();
公共无效添加产品(产品产品)
{
_products.添加(产品);
}
}
[事实]
public void 将产品添加到订单()
{
var product = new Product("手洗");
var sut = 新订单();
sut.添加产品(产品);
断言.等于(1,sut.产品.计数);
断言.Equal(产品,sut.Products[0]);
}public class Order
{
private readonly List<Product> _products = new List<Product>();
public IReadOnlyList<Product> Products => _products.ToList();
public void AddProduct(Product product)
{
_products.Add(product);
}
}
[Fact]
public void Adding_a_product_to_an_order()
{
var product = new Product("Hand wash");
var sut = new Order();
sut.AddProduct(product);
Assert.Equal(1, sut.Products.Count);
Assert.Equal(product, sut.Products[0]);
}
测试在添加完成后验证集合。与清单 6.1Products中基于输出的测试示例不同,测试的结果是订单状态的改变。AddProduct()
The test verifies the Products collection after the addition is completed. Unlike the example of output-based testing in listing 6.1, the outcome of AddProduct() is the change made to the order’s state.
最后,第三种单元测试风格是基于通信的测试。这种风格使用模拟来验证被测系统与其协作者之间的通信(图 6.4)。
Finally, the third style of unit testing is communication-based testing. This style uses mocks to verify communications between the system under test and its collaborators (figure 6.4).
The following listing shows an example of communication-based testing.
[事实]
公共无效发送问候电子邮件()
{
var emailGatewayMock = new Mock<IEmailGateway>();
var sut = new Controller(emailGatewayMock.Object);
sut.GreetUser(“user@email.com”);
emailGatewayMock.验证(
x => x.SendGreetingsEmail("user@email.com"),
次.一次);
}[Fact]
public void Sending_a_greetings_email()
{
var emailGatewayMock = new Mock<IEmailGateway>();
var sut = new Controller(emailGatewayMock.Object);
sut.GreetUser("user@email.com");
emailGatewayMock.Verify(
x => x.SendGreetingsEmail("user@email.com"),
Times.Once);
}
传统的单元测试学派更倾向于基于状态的测试方式,而非基于通信的测试方式。伦敦学派则做出了相反的选择。两所学派都使用基于输出的测试。
The classical school of unit testing prefers the state-based style over the communication-based one. The London school makes the opposite choice. Both schools use output-based testing.
基于输出、基于状态和基于通信的单元测试风格并不是什么新鲜事。事实上,您之前已经在本书中看到过所有这些风格。有趣的是使用良好单元测试的四个属性将它们相互比较。这些属性再次如下(有关更多详细信息,请参阅第 4 章):
There’s nothing new about output-based, state-based, and communication-based styles of unit testing. In fact, you already saw all of these styles previously in this book. What’s interesting is comparing them to each other using the four attributes of a good unit test. Here are those attributes again (refer to chapter 4 for more details):
在我们的比较中,让我们分别看一下这四个。
In our comparison, let’s look at each of the four separately.
首先,让我们从防回归和反馈速度属性方面比较这三种风格,因为这些属性是本次特定比较中最直接的。防回归的指标并不依赖于特定的测试风格。该指标是以下三个特征的产物:
Let’s first compare the three styles in terms of the protection against regressions and feedback speed attributes, as these attributes are the most straightforward in this particular comparison. The metric of protection against regressions doesn’t depend on a particular style of testing. This metric is a product of the following three characteristics:
一般来说,您可以编写测试来测试任意多或任意少的代码;没有哪种特定的风格在这方面有好处。代码的复杂性和领域重要性也是如此。唯一的例外是基于通信的风格:过度使用它会导致浅薄的测试,这些测试只验证一小部分代码并模拟其他所有内容。然而,这种浅薄并不是基于通信的测试的明确特征,而是滥用这种技术的极端情况。
Generally, you can write a test that exercises as much or as little code as you like; no particular style provides a benefit in this area. The same is true for the code’s complexity and domain significance. The only exception is the communication-based style: overusing it can result in shallow tests that verify only a thin slice of code and mock out everything else. Such shallowness is not a definitive feature of communication-based testing, though, but rather is an extreme case of abusing this technique.
测试风格和测试反馈速度之间几乎没有关联。只要您的测试不触及进程外依赖关系,从而保持在单元测试的范围内,所有风格都会产生大致相同执行速度的测试。基于通信的测试可能会稍差一些,因为模拟往往会在运行时引入额外的延迟。但除非您有数万个这样的测试,否则差异可以忽略不计。
There’s little correlation between the styles of testing and the test’s feedback speed. As long as your tests don’t touch out-of-process dependencies and thus stay in the realm of unit testing, all styles produce tests of roughly equal speed of execution. Communication-based testing can be slightly worse because mocks tend to introduce additional latency at runtime. But the difference is negligible, unless you have tens of thousands of such tests.
至于重构阻力的衡量标准,情况则有所不同。重构阻力是衡量重构过程中测试产生多少误报(错误警报)的标准。而误报又是测试与代码的实现细节(而非可观察的行为)耦合的结果。
When it comes to the metric of resistance to refactoring, the situation is different. Resistance to refactoring is the measure of how many false positives (false alarms) tests generate during refactorings. False positives, in turn, are a result of tests coupling to code’s implementation details as opposed to observable behavior.
基于输出的测试能够最好地防止误报,因为生成的测试仅与被测方法耦合。此类测试与实现细节耦合的唯一方式是当被测方法本身就是实现细节时。
Output-based testing provides the best protection against false positives because the resulting tests couple only to the method under test. The only way for such tests to couple to implementation details is when the method under test is itself an implementation detail.
基于状态的测试通常更容易出现误报。除了测试方法之外,此类测试还与类的状态有关。从概率上讲,测试与生产代码之间的耦合度越大,该测试与泄漏的实现细节相关的机会就越大。基于状态的测试与更大的 API 表面相关,因此将它们与实现细节耦合的可能性也更高。
State-based testing is usually more prone to false positives. In addition to the method under test, such tests also work with the class’s state. Probabilistically speaking, the greater the coupling between the test and the production code, the greater the chance for this test to tie to a leaking implementation detail. State-based tests tie to a larger API surface, and hence the chances of coupling them to implementation details are also higher.
基于通信的测试最容易出现误报。您可能还记得第 5 章的内容,绝大多数检查与测试交互的测试双重测试最终会变得脆弱。对于与存根的交互,情况总是如此——您永远不应该检查此类交互。只有当模拟验证跨越应用程序边界的交互并且只有当这些交互的副作用对外部世界可见时,模拟才会没问题。如您所见,使用基于通信的测试需要格外谨慎,以保持适当的重构阻力。
Communication-based testing is the most vulnerable to false alarms. As you may remember from chapter 5, the vast majority of tests that check interactions with test doubles end up being brittle. This is always the case for interactions with stubs—you should never check such interactions. Mocks are fine only when they verify interactions that cross the application boundary and only when the side effects of those interactions are visible to the external world. As you can see, using communication-based testing requires extra prudence in order to maintain proper resistance to refactoring.
但就像浅薄一样,脆弱性也不是基于通信的风格的决定性特征。通过保持适当的封装和仅将测试与可观察的行为耦合,您可以将误报数量降至最低。不过,不可否认的是,尽职调查的程度因单元测试的风格而异。
But just like shallowness, brittleness is not a definitive feature of the communication-based style, either. You can reduce the number of false positives to a minimum by maintaining proper encapsulation and coupling tests to observable behavior only. Admittedly, though, the amount of due diligence varies depending on the style of unit testing.
最后,可维护性指标与单元测试的风格高度相关;但与重构阻力不同,你无法采取太多措施来缓解这种阻力。可维护性评估单元测试的维护成本,并由以下两个特征定义:
Finally, the maintainability metric is highly correlated with the styles of unit testing; but, unlike with resistance to refactoring, there’s not much you can do to mitigate that. Maintainability evaluates the unit tests’ maintenance costs and is defined by the following two characteristics:
测试规模越大,可维护性越差,因为它们越难掌握,在需要时也越难更改。同样,直接使用一个或多个进程外依赖项(如数据库)的测试可维护性越差,因为您需要花时间让这些进程外依赖项保持正常运行:重新启动数据库服务器、解决网络连接问题等。
Larger tests are less maintainable because they are harder to grasp or change when needed. Similarly, a test that directly works with one or several out-of-process dependencies (such as the database) is less maintainable because you need to spend time keeping those out-of-process dependencies operational: rebooting the database server, resolving network connectivity issues, and so on.
与其他两种类型的测试相比,基于输出的测试最易于维护。生成的测试几乎总是简短而简洁,因此更易于维护。基于输出的样式的好处源于这种样式归结为两件事:为方法提供输入并验证其输出,这通常只需几行代码即可完成。
Compared with the other two types of testing, output-based testing is the most maintainable. The resulting tests are almost always short and concise and thus are easier to maintain. This benefit of the output-based style stems from the fact that this style boils down to only two things: supplying an input to a method and verifying its output, which you can often do with just a couple lines of code.
由于基于输出的测试中的底层代码不得更改全局或内部状态,因此这些测试不处理进程外依赖关系。因此,基于输出的测试在可维护性特征方面都是最好的。
Because the underlying code in output-based testing must not change the global or internal state, these tests don’t deal with out-of-process dependencies. Hence, output-based tests are best in terms of both maintainability characteristics.
基于状态的测试通常比基于输出的测试更难维护。这是因为状态验证通常比输出验证占用更多空间。以下是基于状态的测试的另一个示例。
State-based tests are normally less maintainable than output-based ones. This is because state verification often takes up more space than output verification. Here’s another example of state-based testing.
[事实]
public void 将评论添加到文章()
{
var sut = 新文章();
var text = "评论文本";
var 作者 = “John Doe”;
var now = new DateTime(2019, 4, 1);
sut.AddComment(文本,作者,现在);
Assert.Equal(1,sut.Comments.Count); 1
Assert.Equal(文本,sut.Comments[0].Text); 1
Assert.Equal(作者,sut.Comments[0].Author); 1
Assert.Equal(现在,sut.Comments[0].DateCreated); 1
}[Fact]
public void Adding_a_comment_to_an_article()
{
var sut = new Article();
var text = "Comment text";
var author = "John Doe";
var now = new DateTime(2019, 4, 1);
sut.AddComment(text, author, now);
Assert.Equal(1, sut.Comments.Count); 1
Assert.Equal(text, sut.Comments[0].Text); 1
Assert.Equal(author, sut.Comments[0].Author); 1
Assert.Equal(now, sut.Comments[0].DateCreated); 1
}
此测试会向文章添加一条评论,然后检查该评论是否出现在文章的评论列表中。虽然此测试经过简化,仅包含一条评论,但其断言部分已经跨越了四行。基于状态的测试通常需要验证比这多得多的数据,因此其规模可能会显著增加。
This test adds a comment to an article and then checks to see if the comment appears in the article’s list of comments. Although this test is simplified and contains just a single comment, its assertion part already spans four lines. State-based tests often need to verify much more data than that and, therefore, can grow in size significantly.
您可以通过引入隐藏大部分代码的辅助方法来缓解此问题,从而缩短测试(参见清单 6.5 ),但这些方法需要付出大量努力来编写和维护。只有当这些方法将在多个测试中重复使用时,这种努力才是合理的,但这种情况很少见。我将在本书的第 3 部分中详细介绍辅助方法。
You can mitigate this issue by introducing helper methods that hide most of the code and thus shorten the test (see listing 6.5), but these methods require significant effort to write and maintain. This effort is justified only when those methods are going to be reused across multiple tests, which is rarely the case. I’ll explain more about helper methods in part 3 of this book.
[事实]
public void 将评论添加到文章()
{
var sut = 新文章();
var text = "评论文本";
var 作者 = “John Doe”;
var now = new DateTime(2019, 4, 1);
sut.AddComment(文本,作者,现在);
sut.ShouldContainNumberOfComments(1) 1.WithComment
(文本,作者,现在); 1
}[Fact]
public void Adding_a_comment_to_an_article()
{
var sut = new Article();
var text = "Comment text";
var author = "John Doe";
var now = new DateTime(2019, 4, 1);
sut.AddComment(text, author, now);
sut.ShouldContainNumberOfComments(1) 1
.WithComment(text, author, now); 1
}
另一种缩短基于状态的测试的方法是定义被断言的类中的相等成员。清单 6.6中就是这个Comment类。你可以把它变成值对象(其实例通过值而不是通过引用进行比较的类),如下所示;这也会简化测试,特别是如果您将它与 Fluent Assertions 之类的断言库结合起来。
Another way to shorten a state-based test is to define equality members in the class that is being asserted. In listing 6.6, that’s the Comment class. You could turn it into a value object (a class whose instances are compared by value and not by reference), as shown next; this would also simplify the test, especially if you combined it with an assertion library like Fluent Assertions.
[事实]
public void 将评论添加到文章()
{
var sut = 新文章();
var 评论 = 新评论(
“评论文字”,
“约翰·多伊”
新的DateTime(2019,4,1));
sut.添加评论(评论.文本,评论.作者,评论.创建日期);
sut.评论.应该()。BeEquivalentTo(评论);
}[Fact]
public void Adding_a_comment_to_an_article()
{
var sut = new Article();
var comment = new Comment(
"Comment text",
"John Doe",
new DateTime(2019, 4, 1));
sut.AddComment(comment.Text, comment.Author, comment.DateCreated);
sut.Comments.Should().BeEquivalentTo(comment);
}
此测试利用了注释可以作为整体值进行比较这一事实,而无需在其中声明单个属性。它还使用了BeEquivalentToFluent Assertions 中的方法,该方法可以比较整个集合,从而无需检查集合大小。
This test uses the fact that comments can be compared as whole values, without the need to assert individual properties in them. It also uses the BeEquivalentTo method from Fluent Assertions, which can compare entire collections, thereby removing the need to check the collection size.
这是一种强大的技术,但它只在类本身是值并且可以转换为值对象时才有效。否则,它会导致代码污染(用代码污染生产代码库,而这些代码的唯一目的是启用或简化单元测试,如本例所示)。我们将在第 11 章中讨论代码污染以及其他单元测试反模式。
This is a powerful technique, but it works only when the class is inherently a value and can be converted into a value object. Otherwise, it leads to code pollution (polluting production code base with code whose sole purpose is to enable or, as in this case, simplify unit testing). We’ll discuss code pollution along with other unit testing anti-patterns in chapter 11.
如您所见,这两种技术(使用辅助方法和将类转换为值对象)仅偶尔适用。即使这些技术适用,基于状态的测试仍然比基于输出的测试占用更多空间,因此维护性较差。
As you can see, these two techniques—using helper methods and converting classes into value objects—are applicable only occasionally. And even when these techniques are applicable, state-based tests still take up more space than output-based tests and thus remain less maintainable.
基于通信的测试在可维护性指标上的得分低于基于输出和基于状态的测试。基于通信的测试需要设置测试替身和交互断言,这会占用大量空间。当您有模拟链(模拟或存根返回其他模拟,这些模拟也返回模拟,依此类推,深度达几层)时,测试会变得更大且更难维护。
Communication-based tests score worse than output-based and state-based tests on the maintainability metric. Communication-based testing requires setting up test doubles and interaction assertions, and that takes up a lot of space. Tests become even larger and less maintainable when you have mock chains (mocks or stubs returning other mocks, which also return mocks, and so on, several layers deep).
现在让我们使用优秀单元测试的属性来比较单元测试的风格。表 6.1总结了比较结果。如第 6.2.1 节所述,所有三种风格在防止回归和反馈速度的指标上得分相同;因此,我从比较中省略了这些指标。
Let’s now compare the styles of unit testing using the attributes of a good unit test. Table 6.1 sums up the comparison results. As discussed in section 6.2.1, all three styles score equally with the metrics of protection against regressions and feedback speed; hence, I’m omitting these metrics from the comparison.
|
基于状态 State-based |
基于通信 Communication-based |
||
|---|---|---|---|
| 尽职尽责,保持对重构的抵抗力 | 低的 | 中等的 | 中等的 |
| 可维护性成本 | 低的 | 中等的 | 高的 |
基于输出的测试效果最佳。这种测试风格产生的测试很少与实现细节耦合,因此不需要太多努力就能保持适当的抗重构能力。由于这种测试简洁且没有进程外依赖关系,因此也是最易于维护的。
Output-based testing shows the best results. This style produces tests that rarely couple to implementation details and thus don’t require much due diligence to maintain proper resistance to refactoring. Such tests are also the most maintainable due to their conciseness and lack of out-of-process dependencies.
基于状态和基于通信的测试在这两个指标上都表现较差。这些测试更有可能与泄漏的实施细节耦合,并且由于规模较大,维护成本也更高。
State-based and communication-based tests are worse on both metrics. These are more likely to couple to a leaking implementation detail, and they also incur higher maintenance costs due to being larger in size.
始终优先考虑基于输出的测试。不幸的是,说起来容易做起来难。这种单元测试风格仅适用于以函数式编写的代码,而大多数面向对象编程语言很少采用这种风格。不过,您可以使用一些技巧将更多测试转变为基于输出的风格。
Always prefer output-based testing over everything else. Unfortunately, it’s easier said than done. This style of unit testing is only applicable to code that is written in a functional way, which is rarely the case for most object-oriented programming languages. Still, there are techniques you can use to transition more of your tests toward the output-based style.
本章的其余部分将介绍如何从基于状态和基于协作的测试过渡到基于输出的测试。过渡需要您使代码更加纯粹地功能化,从而可以使用基于输出的测试,而不是基于状态或通信的测试。
The rest of this chapter shows how to transition from state-based and collaboration-based testing to output-based testing. The transition requires you to make your code more purely functional, which, in turn, enables the use of output-based tests instead of state- or communication-based ones.
在我展示如何进行转换之前,需要做一些基础工作。在本节中,您将了解函数式编程和函数式架构是什么,以及后者与六边形架构的关系。第 6.4 节使用示例说明了转换。
Some groundwork is needed before I can show how to make the transition. In this section, you’ll see what functional programming and functional architecture are and how the latter relates to the hexagonal architecture. Section 6.4 illustrates the transition using an example.
请注意,这并不是对函数式编程主题的深入探讨,而是对其背后的基本原理的解释。这些基本原理应该足以理解函数式编程和基于输出的测试之间的联系。要深入了解函数式编程,请参阅 Scott Wlaschin 的网站和书籍https://fsharpforfunandprofit.com/books。
Note that this isn’t a deep dive into the topic of functional programming, but rather an explanation of the basic principles behind it. These basic principles should be enough to understand the connection between functional programming and output-based testing. For a deeper look at functional programming, see Scott Wlaschin’s website and books at https://fsharpforfunandprofit.com/books.
正如我在6.1.1 节中提到的那样,基于输出的单元测试风格也称为函数式。这是因为它要求使用函数式编程以纯函数式方式编写底层生产代码。那么,什么是函数式编程?
As I mentioned in section 6.1.1, the output-based unit testing style is also known as functional. That’s because it requires the underlying production code to be written in a purely functional way, using functional programming. So, what is functional programming?
函数式编程是使用数学函数进行编程。数学函数(也称为纯函数)是没有任何隐藏输入或输出的函数(或方法)。数学函数的所有输入和输出都必须在其方法签名中明确表达,该方法签名由方法的名称、参数和返回类型组成。数学函数对于给定的输入无论被调用多少次都会产生相同的输出。
Functional programming is programming with mathematical functions. A mathematical function (also known as pure function) is a function (or method) that doesn’t have any hidden inputs or outputs. All inputs and outputs of a mathematical function must be explicitly expressed in its method signature, which consists of the method’s name, arguments, and return type. A mathematical function produces the same output for a given input regardless of how many times it is called.
我们以清单 6.1CalculateDiscount()中的方法为例(为了方便,我将其复制在这里):
Let’s take the CalculateDiscount() method from listing 6.1 as an example (I’m copying it here for convenience):
公共小数计算折扣(产品[] 产品)
{
小数折扣 = 产品.长度 * 0.01m;
返回 Math.Min(折扣,0.2m);
}public decimal CalculateDiscount(Product[] products)
{
decimal discount = products.Length * 0.01m;
return Math.Min(discount, 0.2m);
}
此方法有一个输入(Product数组)和一个输出(decimal折扣),两者都在方法签名中明确表示。没有隐藏的输入或输出。这构成了CalculateDiscount()一个数学函数(图 6.5)。
This method has one input (a Product array) and one output (the decimal discount), both of which are explicitly expressed in the method’s signature. There are no hidden inputs or outputs. This makes CalculateDiscount() a mathematical function (figure 6.5).
没有隐藏输入和输出的方法被称为数学函数,因为这种方法符合数学中函数的定义。
Methods with no hidden inputs and outputs are called mathematical functions because such methods adhere to the definition of a function in mathematics.
在数学中,函数是两个集合之间的关系,对于第一个集合中的每个元素,都可以在第二个集合中找到一个元素。
In mathematics, a function is a relationship between two sets that for each element in the first set, finds exactly one element in the second set.
图 6.6显示了函数如何针对每个输入数字xf(x) = x + 1找到对应的数字y。图 6.7使用与图 6.6CalculateDiscount()相同的符号显示了该方法。
Figure 6.6 shows how for each input number x, function f(x) = x + 1 finds a corresponding number y. Figure 6.7 displays the CalculateDiscount() method using the same notation as in figure 6.6.
明确的输入和输出使数学函数极易测试,因为生成的测试简短、简单且易于理解和维护。数学函数是唯一可以应用基于输出的测试的方法,它具有最佳的可维护性和最低的误报率。
Explicit inputs and outputs make mathematical functions extremely testable because the resulting tests are short, simple, and easy to understand and maintain. Mathematical functions are the only type of methods where you can apply output-based testing, which has the best maintainability and the lowest chance of producing a false positive.
另一方面,隐藏的输入和输出使代码的可测试性降低(并且可读性也降低)。此类隐藏输入和输出的类型包括:
On the other hand, hidden inputs and outputs make the code less testable (and less readable, too). Types of such hidden inputs and outputs include the following:
判断某个方法是否是数学函数的一个好的经验法则是看你是否可以用该方法的返回值替换对该方法的调用,而不会改变程序的行为。用相应的值替换方法调用的能力称为引用透明性。例如,请看以下方法:
A good rule of thumb when determining whether a method is a mathematical function is to see if you can replace a call to that method with its return value without changing the program’s behavior. The ability to replace a method call with the corresponding value is known as referential transparency. Look at the following method, for example:
公共 int 增量(int x)
{
返回 x + 1;
}public int Increment(int x)
{
return x + 1;
}
This method is a mathematical function. These two statements are equivalent to each other:
int y = 增量(4); int y = 5;
int y = Increment(4); int y = 5;
另一方面,以下方法不是数学函数。您不能用返回值替换它,因为该返回值不代表该方法的所有输出。在此示例中,隐藏的输出是对字段的更改x(副作用):
On the other hand, the following method is not a mathematical function. You can’t replace it with the return value because that return value doesn’t represent all of the method’s outputs. In this example, the hidden output is the change to field x (a side effect):
int x = 0;
公共 int 增量()
{
x++;
返回x;
}int x = 0;
public int Increment()
{
x++;
return x;
}
副作用是最常见的隐藏输出类型。下面的清单展示了一个AddComment表面上看起来像数学函数但实际上不是数学函数的方法。图 6.8以图形方式显示了该方法。
Side effects are the most prevalent type of hidden outputs. The following listing shows an AddComment method that looks like a mathematical function on the surface but actually isn’t one. Figure 6.8 shows the method graphically.
公共评论添加评论(字符串文本)
{
var 评论 = 新评论(文本);
_comments.添加(评论); 1
返回评论;
}public Comment AddComment(string text)
{
var comment = new Comment(text);
_comments.Add(comment); 1
return comment;
}
当然,您无法创建一个完全不产生任何副作用的应用程序。这样的应用程序是不切实际的。毕竟,副作用是您创建所有应用程序的目的:更新用户信息、向购物车添加新订单行等等。
You can’t create an application that doesn’t incur any side effects whatsoever, of course. Such an application would be impractical. After all, side effects are what you create all applications for: updating the user’s information, adding a new order line to the shopping cart, and so on.
函数式编程的目标不是完全消除副作用,而是将处理业务逻辑的代码与产生副作用的代码区分开来。这两项职责本身就足够复杂了;将它们混合在一起会使复杂性成倍增加,从长远来看会阻碍代码的可维护性。这就是函数式架构发挥作用的地方。它通过将副作用推到业务操作的边缘来将业务逻辑与副作用分开。
The goal of functional programming is not to eliminate side effects altogether but rather to introduce a separation between code that handles business logic and code that incurs side effects. These two responsibilities are complex enough on their own; mixing them together multiplies the complexity and hinders code maintainability in the long run. This is where functional architecture comes into play. It separates business logic from side effects by pushing those side effects to the edges of a business operation.
函数式架构最大限度地增加了以纯函数式(不可变)方式编写的代码量,同时最小化了处理副作用的代码。不可变意味着不可改变:一旦创建对象,其状态就无法修改。这与可变对象(可更改对象)形成对比,后者可以在创建后进行修改。
Functional architecture maximizes the amount of code written in a purely functional (immutable) way, while minimizing code that deals with side effects. Immutable means unchangeable: once an object is created, its state can’t be modified. This is in contrast to a mutable object (changeable object), which can be modified after it is created.
通过隔离两种类型的代码来实现业务逻辑和副作用之间的分离:
The separation between business logic and side effects is done by segregating two types of code:
做出决策的代码通常被称为功能核心(也称为不可变核心)。根据这些决策采取行动的代码是可变外壳(图 6.9)。
The code that makes decisions is often referred to as a functional core (also known as an immutable core). The code that acts upon those decisions is a mutable shell (figure 6.9).
The functional core and the mutable shell cooperate in the following way:
为了在这两层之间保持适当的分离,您需要确保代表决策的类包含足够的信息,以便可变外壳无需额外决策即可对其采取行动。换句话说,可变外壳应该尽可能愚蠢。目标是用基于输出的测试广泛覆盖功能核心,并将可变外壳留给更少的集成测试。
To maintain a proper separation between these two layers, you need to make sure the classes representing the decisions contain enough information for the mutable shell to act upon them without additional decision-making. In other words, the mutable shell should be as dumb as possible. The goal is to cover the functional core extensively with output-based tests and leave the mutable shell to a much smaller number of integration tests.
和封装一样,功能架构(一般而言)和不变性(具体而言)与单元测试的目标相同:实现软件项目的可持续发展。事实上,封装和不变性的概念之间存在着深刻的联系。
Like encapsulation, functional architecture (in general) and immutability (in particular) serve the same goal as unit testing: enabling sustainable growth of your software project. In fact, there’s a deep connection between the concepts of encapsulation and immutability.
您可能还记得第 5 章的内容,封装是保护代码免受不一致影响的行为。封装通过以下方式保护类的内部结构免受损坏:
As you may remember from chapter 5, encapsulation is the act of protecting your code against inconsistencies. Encapsulation safeguards the class’s internals from corruption by
不变性从另一个角度解决了保存不变量的问题。使用不变类,您无需担心状态损坏,因为不可能损坏无法更改的内容。因此,函数式编程中无需封装。您只需在创建类的实例时验证一次类的状态。之后,您可以自由地传递此实例。当您的所有数据都是不可变的时,与缺乏封装相关的所有问题都会消失。
Immutability tackles this issue of preserving invariants from another angle. With immutable classes, you don’t need to worry about state corruption because it’s impossible to corrupt something that cannot be changed in the first place. As a consequence, there’s no need for encapsulation in functional programming. You only need to validate the class’s state once, when you create an instance of it. After that, you can freely pass this instance around. When all your data is immutable, the whole set of issues related to the lack of encapsulation simply vanishes.
Michael Feathers 对此有一段精彩的论述:
There’s a great quote from Michael Feathers in that regard:
面向对象编程通过封装移动部件让代码变得易于理解。函数式编程通过最小化移动部件让代码变得易于理解。
Object-oriented programming makes code understandable by encapsulating moving parts. Functional programming makes code understandable by minimizing moving parts.
函数式架构和六边形架构有很多相似之处。它们都是围绕关注点分离的理念构建的。不过,这种分离的细节有所不同。
There are a lot of similarities between functional and hexagonal architectures. Both of them are built around the idea of separation of concerns. The details of that separation vary, though.
您可能还记得第 5 章的内容,六边形架构区分了领域层和应用服务层(图 6.10)。领域层负责业务逻辑,而应用服务层负责与外部应用程序,例如数据库或 SMTP 服务。这与功能架构非常相似,在功能架构中,您可以引入决策和操作的分离。
As you may remember from chapter 5, the hexagonal architecture differentiates the domain layer and the application services layer (figure 6.10). The domain layer is accountable for business logic while the application services layer, for communication with external applications such as a database or an SMTP service. This is very similar to functional architecture, where you introduce the separation of decisions and actions.
另一个相似之处是依赖关系的单向流动。在六边形架构中,域层内的类应该只相互依赖;它们不应该依赖于应用服务层的类。同样,功能架构中的不可变核心不依赖于可变外壳。它是自给自足的,可以独立于外层工作。这就是功能架构如此可测试的原因:您可以将不可变核心从可变外壳中完全剥离,并使用简单值模拟外壳提供的输入。
Another similarity is the one-way flow of dependencies. In the hexagonal architecture, classes inside the domain layer should only depend on each other; they should not depend on classes from the application services layer. Likewise, the immutable core in functional architecture doesn’t depend on the mutable shell. It’s self-sufficient and can work in isolation from the outer layers. This is what makes functional architecture so testable: you can strip the immutable core from the mutable shell entirely and simulate the inputs that the shell provides using simple values.
两者的区别在于对副作用的处理。功能架构将所有副作用从不可变核心推到业务操作的边缘。这些边缘由可变外壳处理。另一方面,六边形架构可以接受领域层产生的副作用,只要它们仅限于该领域层。六边形架构中的所有修改都应包含在领域层内,而不应跨越该层的边界。例如,领域类实例不能直接将某些内容持久保存到数据库,但它可以更改自己的状态。然后,应用服务将获取此更改并将其应用于数据库。
The difference between the two is in their treatment of side effects. Functional architecture pushes all side effects out of the immutable core to the edges of a business operation. These edges are handled by the mutable shell. On the other hand, the hexagonal architecture is fine with side effects made by the domain layer, as long as they are limited to that domain layer only. All modifications in hexagonal architecture should be contained within the domain layer and not cross that layer’s boundary. For example, a domain class instance can’t persist something to the database directly, but it can change its own state. An application service will then pick up this change and apply it to the database.
功能架构是六边形架构的一个子集。你可以将功能架构视为六边形架构的极端版本。
Functional architecture is a subset of the hexagonal architecture. You can view functional architecture as the hexagonal architecture taken to an extreme.
在本节中,我们将采用一个示例应用程序并将其重构为功能架构。您将看到两个重构阶段:
In this section, we’ll take a sample application and refactor it toward functional architecture. You’ll see two refactoring stages:
转换也会影响测试代码!我们将重构基于状态和基于通信的测试,使其采用基于输出的单元测试风格。在开始重构之前,让我们先回顾一下示例项目和涵盖该项目的测试。
The transition affects test code, too! We’ll refactor state-based and communication-based tests to the output-based style of unit testing. Before starting the refactoring, let’s review the sample project and tests covering it.
示例项目是一个审计系统,用于跟踪组织中的所有访问者。它使用纯文本文件作为底层存储,其结构如图 6.11所示。系统将访问者的姓名和访问时间附加到最新文件的末尾。当达到每个文件的最大条目数时,将创建一个具有递增索引的新文件。
The sample project is an audit system that keeps track of all visitors in an organization. It uses flat text files as underlying storage with the structure shown in figure 6.11. The system appends the visitor’s name and the time of their visit to the end of the most recent file. When the maximum number of entries per file is reached, a new file with an incremented index is created.
以下清单显示了该系统的初始版本。
The following listing shows the initial version of the system.
公共类审计管理器
{
私有只读int _maxEntriesPerFile;
私有只读字符串 _directoryName;
公共审计管理器(int maxEntriesPerFile,string directoryName)
{
_每个文件的最大条目数 = 每个文件的最大条目数;
_目录名称 = 目录名称;
}
public void AddRecord(string 访问者姓名,DateTime timeOfVisit)
{
字符串[] filePaths = Directory.GetFiles(_directoryName);
(int 索引,字符串路径)[] sorted = SortByIndex(filePaths);
字符串 newRecord = 访问者姓名 + ';' + timeOfVisit;
如果(sorted.Length == 0)
{
字符串newFile = Path.Combine(_directoryName, "audit_1.txt");
文件.WriteAllText(新文件,新记录);
返回;
}
(int currentFileIndex,字符串currentFilePath) = sorted.Last();
列表 <string> 行 = File.ReadAllLines(currentFilePath).ToList();
如果 (行数.Count < _maxEntriesPerFile)
{
行.添加(新记录);
字符串 newContent = string.Join("\r\n", 行);
文件.WriteAllText(当前文件路径,新内容);
}
别的
{
int 新索引 = 当前文件索引 + 1;
字符串 newName = $“audit_{newIndex}.txt”;
字符串 newFile = Path.Combine(_directoryName,newName);
文件.WriteAllText(新文件,新记录);
}
}
}public class AuditManager
{
private readonly int _maxEntriesPerFile;
private readonly string _directoryName;
public AuditManager(int maxEntriesPerFile, string directoryName)
{
_maxEntriesPerFile = maxEntriesPerFile;
_directoryName = directoryName;
}
public void AddRecord(string visitorName, DateTime timeOfVisit)
{
string[] filePaths = Directory.GetFiles(_directoryName);
(int index, string path)[] sorted = SortByIndex(filePaths);
string newRecord = visitorName + ';' + timeOfVisit;
if (sorted.Length == 0)
{
string newFile = Path.Combine(_directoryName, "audit_1.txt");
File.WriteAllText(newFile, newRecord);
return;
}
(int currentFileIndex, string currentFilePath) = sorted.Last();
List<string> lines = File.ReadAllLines(currentFilePath).ToList();
if (lines.Count < _maxEntriesPerFile)
{
lines.Add(newRecord);
string newContent = string.Join("\r\n", lines);
File.WriteAllText(currentFilePath, newContent);
}
else
{
int newIndex = currentFileIndex + 1;
string newName = $"audit_{newIndex}.txt";
string newFile = Path.Combine(_directoryName, newName);
File.WriteAllText(newFile, newRecord);
}
}
}
代码可能看起来有点大,但其实很简单。AuditManager是应用程序中的主类。其构造函数接受每个文件的最大条目数和工作目录作为配置参数。类中唯一的公共方法是AddRecord,它执行审计系统的所有工作:
The code might look a bit large, but it’s quite simple. AuditManager is the main class in the application. Its constructor accepts the maximum number of entries per file and the working directory as configuration parameters. The only public method in the class is AddRecord, which does all the work of the audit system:
这个AuditManager类很难按原样测试,因为它与文件系统紧密耦合。测试前,你需要将文件放在正确的位置,测试结束后,你需要读取这些文件,检查其内容,然后清除它们(图 6.12)。
The AuditManager class is hard to test as-is, because it’s tightly coupled to the filesystem. Before the test, you’d need to put files in the right place, and after the test finishes, you’d read those files, check their contents, and clear them out (figure 6.12).
您无法并行化此类测试 — 至少,除非付出额外努力,否则维护成本会大幅增加。瓶颈在于文件系统:它是一种共享依赖关系,测试可能会通过它干扰彼此的执行流程。
You won’t be able to parallelize such tests—at least, not without additional effort that would significantly increase maintenance costs. The bottleneck is the filesystem: it’s a shared dependency through which tests can interfere with each other’s execution flow.
文件系统也会使测试变慢。可维护性也会受到影响,因为你必须确保工作目录存在并且可供测试访问——无论是在本地机器上还是在构建服务器上。表 6.2总结了得分。
The filesystem also makes the tests slow. Maintainability suffers, too, because you have to make sure the working directory exists and is accessible to tests—both on your local machine and on the build server. Table 6.2 sums up the scoring.
|
初始版本 Initial version |
|
|---|---|
| 防止回归 | 好的 |
| 抵制重构 | 好的 |
| 快速反馈 | 坏的 |
| 可维护性 | 坏的 |
顺便说一句,直接使用文件系统的测试不符合单元测试的定义。它们不符合单元测试的第二和第三属性,因此属于集成测试类别(有关详细信息,请参阅第 2 章):
By the way, tests working directly with the filesystem don’t fit the definition of a unit test. They don’t comply with the second and the third attributes of a unit test, thereby falling into the category of integration tests (see chapter 2 for more details):
解决紧密耦合测试问题的常用方法是模拟文件系统。您可以将对文件的所有操作提取到一个单独的类(IFileSystem)中,并通过构造函数将该类注入AuditManager。然后,测试将模拟此类并捕获审计系统对文件执行的写入操作(图 6.13)。
The usual solution to the problem of tightly coupled tests is to mock the filesystem. You can extract all operations on files into a separate class (IFileSystem) and inject that class into AuditManager via the constructor. The tests will then mock this class and capture the writes the audit system do to the files (figure 6.13).
下面的清单显示了如何将文件系统注入AuditManager。
The following listing shows how the filesystem is injected into AuditManager.
公共类审计管理器
{
私有只读int _maxEntriesPerFile;
私有只读字符串 _directoryName;
私有只读 IFileSystem _fileSystem; 1
公共审计管理器(
int 每个文件的最大条目数,
字符串目录名称,
IFileSystem 文件系统)
{
_每个文件的最大条目数 = 每个文件的最大条目数;
_目录名称 = 目录名称;
_fileSystem = 文件系统; 1
}
}public class AuditManager
{
private readonly int _maxEntriesPerFile;
private readonly string _directoryName;
private readonly IFileSystem _fileSystem; 1
public AuditManager(
int maxEntriesPerFile,
string directoryName,
IFileSystem fileSystem)
{
_maxEntriesPerFile = maxEntriesPerFile;
_directoryName = directoryName;
_fileSystem = fileSystem; 1
}
}
接下来是AddRecord方法。
And next is the AddRecord method.
public void AddRecord(string 访问者姓名,DateTime timeOfVisit)
{
字符串[] filePaths = _fileSystem 1.GetFiles
(_directoryName); 1
(int 索引,字符串路径)[] sorted = SortByIndex(filePaths);
字符串 newRecord = 访问者姓名 + ';' + timeOfVisit;
如果(sorted.Length == 0)
{
字符串newFile = Path.Combine(_directoryName, "audit_1.txt");
_fileSystem.WriteAllText( 1
新文件, 新记录); 1
返回;
}
(int currentFileIndex,字符串currentFilePath) = sorted.Last();
列表 <string> lines = _fileSystem 1
.ReadAllLines(currentFilePath); 1
如果 (行数.Count < _maxEntriesPerFile)
{
行.添加(新记录);
字符串 newContent = string.Join("\r\n", 行);
_fileSystem.WriteAllText( 1
currentFilePath, newContent); 1
}
别的
{
int 新索引 = 当前文件索引 + 1;
字符串 newName = $“audit_{newIndex}.txt”;
字符串 newFile = Path.Combine(_directoryName,newName);
_fileSystem.WriteAllText( 1
新文件, 新记录); 1
}
}public void AddRecord(string visitorName, DateTime timeOfVisit)
{
string[] filePaths = _fileSystem 1
.GetFiles(_directoryName); 1
(int index, string path)[] sorted = SortByIndex(filePaths);
string newRecord = visitorName + ';' + timeOfVisit;
if (sorted.Length == 0)
{
string newFile = Path.Combine(_directoryName, "audit_1.txt");
_fileSystem.WriteAllText( 1
newFile, newRecord); 1
return;
}
(int currentFileIndex, string currentFilePath) = sorted.Last();
List<string> lines = _fileSystem 1
.ReadAllLines(currentFilePath); 1
if (lines.Count < _maxEntriesPerFile)
{
lines.Add(newRecord);
string newContent = string.Join("\r\n", lines);
_fileSystem.WriteAllText( 1
currentFilePath, newContent); 1
}
else
{
int newIndex = currentFileIndex + 1;
string newName = $"audit_{newIndex}.txt";
string newFile = Path.Combine(_directoryName, newName);
_fileSystem.WriteAllText( 1
newFile, newRecord); 1
}
}
清单6.10是IFileSystem一个新的自定义接口,它封装了与文件系统的工作:
In listing 6.10, IFileSystem is a new custom interface that encapsulates the work with the filesystem:
公共接口IFileSystem
{
字符串[] GetFiles(字符串目录名称);
void WriteAllText(字符串文件路径,字符串内容);
列表<string> ReadAllLines(字符串文件路径);
}public interface IFileSystem
{
string[] GetFiles(string directoryName);
void WriteAllText(string filePath, string content);
List<string> ReadAllLines(string filePath);
}
现在它AuditManager已与文件系统解耦,共享依赖关系已消失,测试可以彼此独立执行。下面是一个这样的测试。
Now that AuditManager is decoupled from the filesystem, the shared dependency is gone, and tests can execute independently from each other. Here’s one such test.
[事实]
public void A_new_file_is_created_when_the_current_file_overflows()
{
var fileSystemMock = new Mock<IFileSystem>();
文件系统模拟
.设置(x => x.GetFiles(“审计”))
.返回(新字符串[]
{
@"审计\审计_1.txt",
@“审计\审计_2.txt”
});
文件系统模拟
.设置(x => x.ReadAllLines(@“audits\audit_2.txt”))
.返回(新列表<string>
{
“Peter; 2019-04-06T16:30:00”
“Jane;2019-04-06T16:40:00”,
“杰克;2019-04-06T17:00:00”
});
var sut = new AuditManager(3, “审计”, fileSystemMock.Object);
sut.AddRecord("爱丽丝",DateTime.Parse("2019-04-06T18:00:00"));
fileSystemMock.Verify(x => x.WriteAllText(
@"审计\审计_3.txt",
“爱丽丝;2019-04-06T18:00:00”));
}[Fact]
public void A_new_file_is_created_when_the_current_file_overflows()
{
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock
.Setup(x => x.GetFiles("audits"))
.Returns(new string[]
{
@"audits\audit_1.txt",
@"audits\audit_2.txt"
});
fileSystemMock
.Setup(x => x.ReadAllLines(@"audits\audit_2.txt"))
.Returns(new List<string>
{
"Peter; 2019-04-06T16:30:00",
"Jane; 2019-04-06T16:40:00",
"Jack; 2019-04-06T17:00:00"
});
var sut = new AuditManager(3, "audits", fileSystemMock.Object);
sut.AddRecord("Alice", DateTime.Parse("2019-04-06T18:00:00"));
fileSystemMock.Verify(x => x.WriteAllText(
@"audits\audit_3.txt",
"Alice;2019-04-06T18:00:00"));
}
此测试验证当当前文件中的条目数达到限制(3在本例中为)时,是否会创建一个包含单个审计条目的新文件。请注意,这是模拟的合法用法。应用程序创建的文件对最终用户可见(假设这些用户使用另一个程序读取文件,无论是专门的软件还是简单的 notepad.exe)。因此,与文件系统的通信以及这些通信的副作用(即文件中的更改)是应用程序可观察行为的一部分。您可能还记得第 5 章的内容,这是模拟的唯一合法用例。
This test verifies that when the number of entries in the current file reaches the limit (3, in this example), a new file with a single audit entry is created. Note that this is a legitimate use of mocks. The application creates files that are visible to end users (assuming that those users use another program to read the files, be it specialized software or a simple notepad.exe). Therefore, communications with the filesystem and the side effects of these communications (that is, the changes in files) are part of the application’s observable behavior. As you may remember from chapter 5, that’s the only legitimate use case for mocking.
这种替代实现是对初始版本的改进。由于测试不再访问文件系统,因此执行速度更快。而且由于您不需要照看文件系统来确保测试顺利进行,因此维护成本也降低了。对回归的保护和对重构的抵抗也没有受到重构的影响。表 6.3显示了两个版本之间的差异。
This alternative implementation is an improvement over the initial version. Since tests no longer access the filesystem, they execute faster. And because you don’t need to look after the filesystem to keep the tests happy, the maintenance costs are also reduced. Protection against regressions and resistance to refactoring didn’t suffer from the refactoring either. Table 6.3 shows the differences between the two versions.
|
初始版本 Initial version |
使用 mock With mocks |
|
|---|---|---|
| 防止回归 | 好的 | 好的 |
| 抵制重构 | 好的 | 好的 |
| 快速反馈 | 坏的 | 好的 |
| 可维护性 | 坏的 | 缓和 |
不过,我们还可以做得更好。清单 6.11中的测试包含复杂的设置,从维护成本来看并不理想。模拟库尽力提供帮助,但生成的测试仍然不如那些依赖于简单输入和输出的测试可读性强。
We can still do better, though. The test in listing 6.11 contains convoluted setups, which is less than ideal in terms of maintenance costs. Mocking libraries try their best to be helpful, but the resulting tests are still not as readable as those that rely on plain input and output.
您无需将副作用隐藏在接口后面,再将接口注入AuditManager,而是可以将这些副作用完全移出类。AuditManager这样, 仅负责决定如何处理文件。 一个新类 会Persister根据该决定采取行动,并将更新应用于文件系统(图 6.14)。
Instead of hiding side effects behind an interface and injecting that interface into AuditManager, you can move those side effects out of the class entirely. AuditManager is then only responsible for making a decision about what to do with the files. A new class, Persister, acts on that decision and applies updates to the filesystem (figure 6.14).
Persister在这个场景中, 充当可变的外壳,而 则AuditManager成为功能性(不可变)核心。AuditManager重构后的代码清单如下。
Persister in this scenario acts as a mutable shell, while AuditManager becomes a functional (immutable) core. The following listing shows AuditManager after the refactoring.
公共类审计管理器
{
私有只读int _maxEntriesPerFile;
公共审计管理器(int maxEntriesPerFile)
{
_每个文件的最大条目数 = 每个文件的最大条目数;
}
公共文件更新添加记录(
FileContent[] 文件,
字符串访问者姓名,
日期时间访问时间)
{
(int 索引,FileContent 文件)[] sorted = SortByIndex(文件);
字符串 newRecord = 访问者姓名 + ';' + timeOfVisit;
如果(sorted.Length == 0)
{
返回新的 FileUpdate( 1
"audit_1.txt", newRecord); 1
}
(int currentFileIndex,FileContent currentFile)= sorted.Last();
列表 <string> lines = currentFile.Lines.ToList();
如果 (行数.Count < _maxEntriesPerFile)
{
行.添加(新记录);
字符串 newContent = string.Join("\r\n", 行);
返回新的 FileUpdate( 1
currentFile.FileName, newContent); 1
}
别的
{
int 新索引 = 当前文件索引 + 1;
字符串 newName = $“audit_{newIndex}.txt”;
返回新的 FileUpdate( 1
newName, newRecord); 1
}
}
}public class AuditManager
{
private readonly int _maxEntriesPerFile;
public AuditManager(int maxEntriesPerFile)
{
_maxEntriesPerFile = maxEntriesPerFile;
}
public FileUpdate AddRecord(
FileContent[] files,
string visitorName,
DateTime timeOfVisit)
{
(int index, FileContent file)[] sorted = SortByIndex(files);
string newRecord = visitorName + ';' + timeOfVisit;
if (sorted.Length == 0)
{
return new FileUpdate( 1
"audit_1.txt", newRecord); 1
}
(int currentFileIndex, FileContent currentFile) = sorted.Last();
List<string> lines = currentFile.Lines.ToList();
if (lines.Count < _maxEntriesPerFile)
{
lines.Add(newRecord);
string newContent = string.Join("\r\n", lines);
return new FileUpdate( 1
currentFile.FileName, newContent); 1
}
else
{
int newIndex = currentFileIndex + 1;
string newName = $"audit_{newIndex}.txt";
return new FileUpdate( 1
newName, newRecord); 1
}
}
}
现在不再接受工作目录路径,而是AuditManager接受一个数组FileContent。此类包含AuditManager做出决策所需的有关文件系统的所有信息:
Instead of the working directory path, AuditManager now accepts an array of FileContent. This class includes everything AuditManager needs to know about the filesystem to make a decision:
公共类文件内容
{
公共只读字符串文件名;
公共只读字符串[] 行;
公共文件内容(字符串文件名,字符串[] 行)
{
文件名 = 文件名;
线条=线条;
}
}public class FileContent
{
public readonly string FileName;
public readonly string[] Lines;
public FileContent(string fileName, string[] lines)
{
FileName = fileName;
Lines = lines;
}
}
并且,现在不会改变工作目录中的文件,而是AuditManager返回想要执行的副作用的指令:
And, instead of mutating files in the working directory, AuditManager now returns an instruction for the side effect it would like to perform:
公共类文件更新
{
公共只读字符串文件名;
公共只读字符串NewContent;
公共 FileUpdate(字符串文件名,字符串新内容)
{
文件名 = 文件名;
新内容=新内容;
}
}public class FileUpdate
{
public readonly string FileName;
public readonly string NewContent;
public FileUpdate(string fileName, string newContent)
{
FileName = fileName;
NewContent = newContent;
}
}
The following listing shows the Persister class.
公共课程 Persister
{
公共文件内容[]读取目录(字符串目录名)
{
返回目录
.GetFiles(目录名称)
.选择(x => 新文件内容(
路径.获取文件名(x),
文件.读取所有行(x)))
.至数组();
}
公共无效ApplyUpdate(字符串目录名称,FileUpdate更新)
{
字符串文件路径 = Path.Combine(目录名称,更新文件名称);
文件.WriteAllText(文件路径,更新.NewContent);
}
}public class Persister
{
public FileContent[] ReadDirectory(string directoryName)
{
return Directory
.GetFiles(directoryName)
.Select(x => new FileContent(
Path.GetFileName(x),
File.ReadAllLines(x)))
.ToArray();
}
public void ApplyUpdate(string directoryName, FileUpdate update)
{
string filePath = Path.Combine(directoryName, update.FileName);
File.WriteAllText(filePath, update.NewContent);
}
}
注意这个类是多么的简单。它所做的就是从工作目录中读取内容,并将从中收到的更新应用AuditManager回该工作目录。它没有分支(没有if语句);所有的复杂性都存在于AuditManager类中。这是业务逻辑和实际副作用之间的分离。
Notice how trivial this class is. All it does is read content from the working directory and apply updates it receives from AuditManager back to that working directory. It has no branching (no if statements); all the complexity resides in the AuditManager class. This is the separation between business logic and side effects in action.
FileContent为了保持这种分离,您需要使和的接口FileUpdate尽可能接近框架内置文件交互命令的接口。所有解析和准备工作都应在功能核心中完成,以便核心之外的代码保持简单。例如,如果 .NET 不包含内置File.ReadAllLines()方法(该方法将文件内容作为行数组返回),而只有 (File.ReadAllText()返回单个字符串),则您需要将Lines中的属性替换FileContent为string,并在 中进行解析AuditManager:
To maintain such a separation, you need to keep the interface of FileContent and FileUpdate as close as possible to that of the framework’s built-in file-interaction commands. All the parsing and preparation should be done in the functional core, so that the code outside of that core remains trivial. For example, if .NET didn’t contain the built-in File.ReadAllLines() method, which returns the file content as an array of lines, and only has File.ReadAllText(), which returns a single string, you’d need to replace the Lines property in FileContent with a string too and do the parsing in AuditManager:
公共类文件内容
{
公共只读字符串文件名;
公共只读字符串文本;//以前,string[]行;
}public class FileContent
{
public readonly string FileName;
public readonly string Text; // previously, string[] Lines;
}
为了将AuditManager和Persister粘合在一起,您需要另一个类:六边形架构分类法中的应用服务,如下面的清单所示。
To glue AuditManager and Persister together, you need another class: an application service in the hexagonal architecture taxonomy, as shown in the following listing.
公共类应用服务
{
私有只读字符串 _directoryName;
私有只读审计管理器_auditManager;
私有只读持久化_persister;
公共应用服务(
字符串目录名称,整数maxEntriesPerFile)
{
_目录名称 = 目录名称;
_auditManager = 新的 AuditManager(maxEntriesPerFile);
_persister = 新的 Persister();
}
public void AddRecord(string 访问者姓名,DateTime timeOfVisit)
{
文件内容[] 文件 = _persister.ReadDirectory(_directoryName);
FileUpdate 更新 = _auditManager.AddRecord(
文件,访问者姓名,访问时间);
_persister.ApplyUpdate(_directoryName, 更新);
}
}public class ApplicationService
{
private readonly string _directoryName;
private readonly AuditManager _auditManager;
private readonly Persister _persister;
public ApplicationService(
string directoryName, int maxEntriesPerFile)
{
_directoryName = directoryName;
_auditManager = new AuditManager(maxEntriesPerFile);
_persister = new Persister();
}
public void AddRecord(string visitorName, DateTime timeOfVisit)
{
FileContent[] files = _persister.ReadDirectory(_directoryName);
FileUpdate update = _auditManager.AddRecord(
files, visitorName, timeOfVisit);
_persister.ApplyUpdate(_directoryName, update);
}
}
除了将功能核心与可变外壳粘合在一起之外,应用服务还为外部客户端提供了系统的入口点(图 6.15)。通过此实现,可以轻松检查审计系统的行为。现在,所有测试都归结为提供工作目录的假设状态并验证决策AuditManager。
Along with gluing the functional core together with the mutable shell, the application service also provides an entry point to the system for external clients (figure 6.15). With this implementation, it becomes easy to check the audit system’s behavior. All tests now boil down to supplying a hypothetical state of the working directory and verifying the decision AuditManager makes.
[事实]
public void A_new_file_is_created_when_the_current_file_overflows()
{
var sut = new AuditManager(3);
var 文件 = 新文件内容[]
{
新文件内容(“audit_1.txt”,新字符串[0]),
新文件内容(“audit_2.txt”,新字符串[]
{
“Peter; 2019-04-06T16:30:00”
“Jane;2019-04-06T16:40:00”,
“杰克;2019-04-06T17:00:00”
})
};
FileUpdate 更新 = sut.AddRecord(
文件,“爱丽丝”,DateTime.Parse(“2019-04-06T18:00:00”));
断言.Equal("audit_3.txt", 更新.文件名);
Assert.Equal("爱丽丝;2019-04-06T18:00:00", update.NewContent);
}[Fact]
public void A_new_file_is_created_when_the_current_file_overflows()
{
var sut = new AuditManager(3);
var files = new FileContent[]
{
new FileContent("audit_1.txt", new string[0]),
new FileContent("audit_2.txt", new string[]
{
"Peter; 2019-04-06T16:30:00",
"Jane; 2019-04-06T16:40:00",
"Jack; 2019-04-06T17:00:00"
})
};
FileUpdate update = sut.AddRecord(
files, "Alice", DateTime.Parse("2019-04-06T18:00:00"));
Assert.Equal("audit_3.txt", update.FileName);
Assert.Equal("Alice;2019-04-06T18:00:00", update.NewContent);
}
此测试保留了模拟测试相对于初始版本的改进(快速反馈),同时还进一步提高了可维护性指标。不再需要复杂的模拟设置,只需要简单的输入和输出,这大大提高了测试的可读性。表 6.4将基于输出的测试与初始版本和模拟版本进行了比较。
This test retains the improvement the test with mocks made over the initial version (fast feedback) but also further improves on the maintainability metric. There’s no need for complex mock setups anymore, only plain inputs and outputs, which helps the test’s readability a lot. Table 6.4 compares the output-based test with the initial version and the version with mocks.
|
初始版本 Initial version |
使用 mock With mocks |
以产出为基础 Output-based |
|
|---|---|---|---|
| 防止回归 | 好的 | 好的 | 好的 |
| 抵制重构 | 好的 | 好的 | 好的 |
| 快速反馈 | 坏的 | 好的 | 好的 |
| 可维护性 | 坏的 | 缓和 | 好的 |
请注意,功能核心生成的指令始终是一个值或一组值。只要内容匹配,这种值的两个实例就可以互换。您可以利用这一事实,通过转换FileUpdate为值对象进一步提高测试的可读性。要在 .NET 中做到这一点,您需要将类转换为或定义自定义相等成员。这将为您提供按值比较,而不是按引用比较,这是 C# 中类的默认行为。按值比较还允许您将清单 6.15struct中的两个断言压缩为一个:
Notice that the instructions generated by a functional core are always a value or a set of values. Two instances of such a value are interchangeable as long as their contents match. You can take advantage of this fact and improve test readability even further by turning FileUpdate into a value object. To do that in .NET, you need to either convert the class into a struct or define custom equality members. That will give you comparison by value, as opposed to the comparison by reference, which is the default behavior for classes in C#. Comparison by value also allows you to compress the two assertions from listing 6.15 into one:
断言.等于(
新文件更新(“audit_3.txt”,“Alice;2019-04-06T18:00:00”),
更新);Assert.Equal(
new FileUpdate("audit_3.txt", "Alice;2019-04-06T18:00:00"),
update);
或者,使用 Fluent Assertions,
Or, using Fluent Assertions,
更新.应该().是(
新FileUpdate(“audit_3.txt”,“Alice; 2019-04-06T18:00:00”));update.Should().Be(
new FileUpdate("audit_3.txt", "Alice;2019-04-06T18:00:00"));
让我们回顾一下,看看我们的示例项目中可以进行的进一步开发。我向您展示的审计系统非常简单,仅包含三个分支:
Let’s step back for a minute and look at further developments that could be done in our sample project. The audit system I showed you is quite simple and contains only three branches:
此外,只有一个用例:向审计日志添加新条目。如果还有其他用例,比如删除特定访客的所有提及,该怎么办?如果系统需要进行验证(例如,验证访客姓名的最大长度),该怎么办?
Also, there’s only one use case: addition of a new entry to the audit log. What if there were another use case, such as deleting all mentions of a particular visitor? And what if the system needed to do validations (say, for the maximum length of the visitor’s name)?
删除特定访问者的所有提及可能会影响多个文件,因此新方法需要返回多个文件指令:
Deleting all mentions of a particular visitor could potentially affect several files, so the new method would need to return multiple file instructions:
公共文件更新[] DeleteAllMentions(
FileContent[] 文件, 字符串访问者名称)public FileUpdate[] DeleteAllMentions(
FileContent[] files, string visitorName)
此外,业务人员可能要求您不要在工作目录中保留空文件。如果删除的条目是审计文件中的最后一个条目,则您需要将该文件完全删除。为了实现此要求,您可以重命名FileUpdate并FileAction引入一个额外的ActionType枚举字段来指示它是更新还是删除。
Furthermore, business people might require that you not keep empty files in the working directory. If the deleted entry was the last entry in an audit file, you would need to remove that file altogether. To implement this requirement, you could rename FileUpdate to FileAction and introduce an additional ActionType enum field to indicate whether it was an update or a deletion.
函数式架构让错误处理变得更简单、更明确。你可以将错误嵌入到方法的签名中,无论是在FileUpdate类中还是作为单独的组件:
Error handling also becomes simpler and more explicit with functional architecture. You could embed errors into the method’s signature, either in the FileUpdate class or as a separate component:
public (FileUpdate 更新, Error 错误) AddRecord(
FileContent[] 文件,
字符串访问者姓名,
日期时间访问时间)public (FileUpdate update, Error error) AddRecord(
FileContent[] files,
string visitorName,
DateTime timeOfVisit)
然后,应用服务会检查是否存在此错误。如果存在,服务不会将更新指令传递给持久化程序,而是向用户传播错误消息。
The application service would then check for this error. If it was there, the service wouldn’t pass the update instruction to the persister, instead propagating an error message to the user.
不幸的是,函数式架构并不总是可以实现的。即使可以实现,可维护性优势也常常被性能影响和代码库规模的增加所抵消。在本节中,我们将探讨函数式架构的成本和权衡。
Unfortunately, functional architecture isn’t always attainable. And even when it is, the maintainability benefits are often offset by a performance impact and increase in the size of the code base. In this section, we’ll explore the costs and the trade-offs attached to functional architecture.
功能架构适用于我们的审计系统,因为该系统可以在做出决策之前预先收集所有输入。不过,执行流程通常不那么简单。您可能需要根据决策过程的中间结果从进程外依赖项中查询其他数据。
Functional architecture worked for our audit system because this system could gather all the inputs up front, before making a decision. Often, though, the execution flow is less straightforward. You might need to query additional data from an out-of-process dependency, based on an intermediate result of the decision-making process.
这是一个例子。假设审计系统需要检查访问者的访问级别,如果访问者在过去 24 小时内的访问次数超过某个阈值。我们还假设所有访问者的访问级别都存储在数据库中。您不能像这样传递一个IDatabase实例AuditManager:
Here’s an example. Let’s say the audit system needs to check the visitor’s access level if the number of times they have visited during the last 24 hours exceeds some threshold. And let’s also assume that all visitors’ access levels are stored in a database. You can’t pass an IDatabase instance to AuditManager like this:
公共文件更新添加记录(
FileContent[] 文件,字符串访问者名称,
DateTime timeOfVisit,IDatabase 数据库
)public FileUpdate AddRecord(
FileContent[] files, string visitorName,
DateTime timeOfVisit, IDatabase database
)
这种情况下,方法中就会引入隐藏的输入AddRecord()。因此,该方法将不再是一个数学函数(图 6.16),这意味着你将无法再应用基于输出的测试。
Such an instance would introduce a hidden input to the AddRecord() method. This method would, therefore, cease to be a mathematical function (figure 6.16), which means you would no longer be able to apply output-based testing.
对于这种情况有两种解决方案:
There are two solutions in such a situation:
这两种方法都有缺点。第一种方法牺牲了性能——它无条件地查询数据库,即使在不需要访问级别的情况下也是如此。但这种方法保持了业务逻辑与外部通信的分离系统完全完好:所有决策都AuditManager像以前一样在 中。第二种方法为了提高性能而放弃了一定程度的分离:是否调用数据库的决定现在由应用服务来决定,而不是AuditManager。
Both approaches have drawbacks. The first one concedes performance—it unconditionally queries the database, even in cases when the access level is not required. But this approach keeps the separation of business logic and communication with external systems fully intact: all decision-making resides in AuditManager as before. The second approach concedes a degree of that separation for performance gains: the decision as to whether to call the database now goes to the application service, not AuditManager.
请注意,与这两个选项不同,让域模型(AuditManager)依赖于数据库并不是一个好主意。我将在接下来的两章中进一步解释如何在性能和关注点分离之间保持平衡。
Note that, unlike these two options, making the domain model (AuditManager) depend on the database isn’t a good idea. I’ll explain more about keeping the balance between performance and separation of concerns in the next two chapters.
您可能已经注意到,AuditManager的AddRecord()方法具有其签名中不存在的依赖项:_maxEntriesPerFile字段。审计管理器参考此字段来决定是附加现有审计文件还是创建新审计文件。
You may have noticed that AuditManager’s AddRecord() method has a dependency that’s not present in its signature: the _maxEntriesPerFile field. The audit manager refers to this field to make a decision to either append an existing audit file or create a new one.
虽然此依赖关系不存在于方法的参数中,但它并非隐藏的。它可以从类的构造函数签名中派生出来。并且由于字段_maxEntriesPerFile是不可变的,因此它在类实例化和调用之间保持不变AddRecord()。换句话说,该字段是一个值。
Although this dependency isn’t present among the method’s arguments, it’s not hidden. It can be derived from the class’s constructor signature. And because the _maxEntriesPerFile field is immutable, it stays the same between the class instantiation and the call to AddRecord(). In other words, that field is a value.
依赖项的情况IDatabase有所不同,因为它是一个协作者,而不是像这样的值_maxEntriesPerFile。您可能还记得第 2 章的内容,协作者是以下依赖项之一:
The situation with the IDatabase dependency is different because it’s a collaborator, not a value like _maxEntriesPerFile. As you may remember from chapter 2, a collaborator is a dependency that is one or the other of the following:
该IDatabase实例属于第二类,因此是协作者。它需要额外调用进程外依赖项,因此无法使用基于输出的测试。
The IDatabase instance falls into the second category and, therefore, is a collaborator. It requires an additional call to an out-of-process dependency and thus precludes the use of output-based testing.
来自功能核心的类不应该与合作者一起工作,而应该与其工作的成果即价值一起工作。
A class from the functional core should work not with a collaborator, but with the product of its work, a value.
对整个系统的性能影响是反对功能架构的常见论点。请注意,受影响的不是测试的性能。我们最终进行的基于输出的测试与使用模拟的测试一样快。而是系统本身现在必须对进程外依赖项进行更多调用,性能会降低。审计系统的初始版本没有从工作目录中读取所有文件,使用模拟的版本也没有。但最终版本这样做是为了符合读取-决定-行动的方法。
The performance impact on the system as a whole is a common argument against functional architecture. Note that it’s not the performance of tests that suffers. The output-based tests we ended up with work as fast as the tests with mocks. It’s that the system itself now has to do more calls to out-of-process dependencies and becomes less performant. The initial version of the audit system didn’t read all files from the working directory, and neither did the version with mocks. But the final version does in order to comply with the read-decide-act approach.
在功能架构和更传统的架构之间做出选择,需要在性能和代码可维护性(生产代码和测试代码)之间进行权衡。在某些性能影响不太明显的系统中,最好采用功能架构以获得额外的可维护性。在其他情况下,您可能需要做出相反的选择。没有一刀切的解决方案。
The choice between a functional architecture and a more traditional one is a trade-off between performance and code maintainability (both production and test code). In some systems where the performance impact is not as noticeable, it’s better to go with functional architecture for additional gains in maintainability. In others, you might need to make the opposite choice. There’s no one-size-fits-all solution.
代码库的大小也是如此。功能架构要求功能(不可变)核心和可变外壳之间有明确的区分。这最初需要额外的编码,但最终会降低代码复杂性并提高可维护性。
The same is true for the size of the code base. Functional architecture requires a clear separation between the functional (immutable) core and the mutable shell. This necessitates additional coding initially, although it ultimately results in reduced code complexity and gains in maintainability.
不过,并非所有项目的复杂程度都足以证明这样的初始投资是合理的。有些代码库从业务角度来看并不那么重要,或者只是太简单了。在这样的项目中使用功能架构是没有意义的,因为初始投资永远不会有回报。始终要战略性地应用功能架构,同时考虑到系统的复杂性和重要性。
Not all projects exhibit a high enough degree of complexity to justify such an initial investment, though. Some code bases aren’t that significant from a business perspective or are just plain too simple. It doesn’t make sense to use functional architecture in such projects because the initial investment will never pay off. Always apply functional architecture strategically, taking into account the complexity and importance of your system.
最后,如果纯粹的函数式方法代价太高,就不要追求这种纯粹。在大多数项目中,您无法使域模型完全不可变,因此不能完全依赖基于输出的测试,至少在使用 C# 或 Java 等 OOP 语言时不能。在大多数情况下,您会结合使用基于输出和基于状态的样式,并混合少量基于通信的测试,这很好。本章的目标不是鼓励您将所有测试都转换为基于输出的样式;目标是尽可能多地转换它们。区别很微妙,但很重要。
Finally, don’t go for purity of the functional approach if that purity comes at too high a cost. In most projects, you won’t be able to make the domain model fully immutable and thus can’t rely solely on output-based tests, at least not when using an OOP language like C# or Java. In most cases, you’ll have a combination of output-based and state-based styles, with a small mix of communication-based tests, and that’s fine. The goal of this chapter is not to incite you to transition all your tests toward the output-based style; the goal is to transition as many of them as reasonably possible. The difference is subtle but important.
本章涵盖
This chapter covers
在第 1 章中,我定义了优秀单元测试套件的属性:
In chapter 1, I defined the properties of a good unit test suite:
第 4 章介绍了如何使用四个属性来识别有价值的测试:防止回归、抗重构、快速反馈和可维护性。第 5 章详细阐述了其中最重要的一个:抗重构。
Chapter 4 covered the topic of recognizing a valuable test using the four attributes: protection against regressions, resistance to refactoring, fast feedback, and maintainability. And chapter 5 expanded on the most important one of the four: resistance to refactoring.
正如我之前提到的,仅仅识别有价值的测试是不够的,你还应该能够编写这样的测试。后者需要前者的技能,但也需要您了解代码设计技术。单元测试和底层代码紧密相关,如果不花功夫研究它们所涵盖的代码库,就不可能创建有价值的测试。
As I mentioned earlier, it’s not enough to recognize valuable tests, you should also be able to write such tests. The latter skill requires the former, but it also requires that you know code design techniques. Unit tests and the underlying code are highly intertwined, and it’s impossible to create valuable tests without putting effort into the code base they cover.
您在第 6 章中看到了代码库转换的示例,其中我们将审计系统重构为功能架构,因此能够应用基于输出的测试。本章将这种方法推广到更广泛的应用程序,包括那些不能使用功能架构的应用程序。您将看到有关如何在几乎所有软件项目中编写有价值的测试的实用指南。
You saw an example of a code base transformation in chapter 6, where we refactored an audit system toward a functional architecture and, as a result, were able to apply output-based testing. This chapter generalizes this approach onto a wider spectrum of applications, including those that can’t use a functional architecture. You’ll see practical guidelines on how to write valuable tests in almost any software project.
如果不重构底层代码,几乎不可能显著改善测试套件。这是无法回避的——测试和生产代码本质上是相连的。在本节中,您将了解如何将代码分为四种类型,以概述重构的方向。后续部分将展示一个全面的示例。
It’s rarely possible to significantly improve a test suite without refactoring the underlying code. There’s no way around it—test and production code are intrinsically connected. In this section, you’ll see how to categorize your code into the four types in order to outline the direction of the refactoring. The subsequent sections show a comprehensive example.
在本节中,我将描述作为本章其余部分基础的四种代码类型。
In this section, I describe the four types of code that serve as a foundation for the rest of this chapter.
所有生产代码都可以按照两个维度进行分类:
All production code can be categorized along two dimensions:
代码复杂度由代码中的决策点(分支点)数量定义。该数字越大,复杂度越高。
Code complexity is defined by the number of decision-making (branching) points in the code. The greater that number, the higher the complexity.
在计算机科学中,有一个专门的术语来描述代码复杂度:循环复杂度。循环复杂度表示给定程序或方法中的分支数量。该指标的计算方式如下
In computer science, there’s a special term that describes code complexity: cyclomatic complexity. Cyclomatic complexity indicates the number of branches in a given program or method. This metric is calculated as
1 + <分支点数>
1 + <number of branching points>
因此,没有控制流语句(例如if语句或条件循环)的方法的循环复杂度为 1 + 0 = 1。
Thus, a method with no control flow statements (such as if statements or conditional loops) has a cyclomatic complexity of 1 + 0 = 1.
这个指标还有另一层含义。您可以将其视为从入口到出口的独立路径数,或者实现 100% 分支覆盖率所需的测试数。
There’s another meaning to this metric. You can think of it in terms of the number of independent paths through the method from an entry to an exit, or the number of tests needed to get a 100% branch coverage.
请注意,分支点的数量按所涉及的最简单谓词的数量计算。例如,类似于的语句IF condition1 AND condition2 THEN ...相当于IF condition1 THEN IF condition2 THEN ...因此,其复杂度为1 + 2 = 3。
Note that the number of branching points is counted as the number of simplest predicates involved. For instance, a statement like IF condition1 AND condition2 THEN ... is equivalent to IF condition1 THEN IF condition2 THEN ... Therefore, its complexity would be 1 + 2 = 3.
领域重要性表明代码对于项目的问题领域有多重要。通常,领域层中的所有代码都与最终用户的目标有直接联系,因此具有较高的领域重要性。另一方面,实用程序代码没有这样的联系。
Domain significance shows how significant the code is for the problem domain of your project. Normally, all code in the domain layer has a direct connection to the end users’ goals and thus exhibits a high domain significance. On the other hand, utility code doesn’t have such a connection.
复杂代码和具有领域重要性的代码从单元测试中获益最多,因为相应的测试可以很好地防止回归。请注意,领域代码不必很复杂,复杂代码也不必表现出领域重要性才值得测试。这两个组件彼此独立。例如,计算订单价格的方法可以不包含条件语句,因此其循环复杂度为1。尽管如此,测试这样的方法仍然很重要,因为它代表了业务关键功能。
Complex code and code that has domain significance benefit from unit testing the most because the corresponding tests have great protection against regressions. Note that the domain code doesn’t have to be complex, and complex code doesn’t have to exhibit domain significance to be test-worthy. The two components are independent of each other. For example, a method calculating an order price can contain no conditional statements and thus have the cyclomatic complexity of 1. Still, it’s important to test such a method because it represents business-critical functionality.
第二个维度是类或方法具有的协作者的数量。您可能还记得第 2 章的内容,协作者是一种可变或进程外(或两者兼而有之)的依赖项。具有大量协作者的代码测试起来很昂贵。这是由于可维护性指标,它取决于测试的大小。需要空间来使协作者达到预期状态,然后检查他们的状态或与他们的交互。协作者越多,测试就越大。
The second dimension is the number of collaborators a class or a method has. As you may remember from chapter 2, a collaborator is a dependency that is either mutable or out-of-process (or both). Code with a large number of collaborators is expensive to test. That’s due to the maintainability metric, which depends on the size of the test. It takes space to bring collaborators to an expected condition and then check their state or interactions with them afterward. And the more collaborators there are, the larger the test becomes.
协作者的类型也很重要。对于领域模型来说,进程外协作者是行不通的。由于需要在测试中维护复杂的模拟机制,它们会增加额外的维护成本。您还必须格外谨慎,只使用模拟来验证跨越应用程序边界的交互,以保持适当的重构阻力(有关更多详细信息,请参阅第 5 章)。最好将所有与进程外依赖项的通信委托给领域层之外的类。这样,领域类将只处理进程内依赖项。
The type of the collaborators also matters. Out-of-process collaborators are a no-go when it comes to the domain model. They add additional maintenance costs due to the necessity to maintain complicated mock machinery in tests. You also have to be extra prudent and only use mocks to verify interactions that cross the application boundary in order to maintain proper resistance to refactoring (refer to chapter 5 for more details). It’s better to delegate all communications with out-of-process dependencies to classes outside the domain layer. The domain classes then will only work with in-process dependencies.
请注意,隐式和显式协作者都计入此数字。无论被测系统 (SUT) 接受协作者作为参数还是通过静态方法隐式引用它,您仍然必须在测试中设置此协作者。相反,不可变依赖项(值或值对象)不计入。此类依赖项设置和断言起来要容易得多。
Notice that both implicit and explicit collaborators count toward this number. It doesn’t matter if the system under test (SUT) accepts a collaborator as an argument or refers to it implicitly via a static method, you still have to set up this collaborator in tests. Conversely, immutable dependencies (values or value objects) don’t count. Such dependencies are much easier to set up and assert against.
代码复杂度、其领域重要性以及协作者的数量相结合,为我们提供了图 7.1所示的四种代码类型:
The combination of code complexity, its domain significance, and the number of collaborators give us the four types of code shown in figure 7.1:
对左上象限(领域模型和算法)进行单元测试可为您带来最佳回报。由此产生的单元测试非常有价值且成本低廉。它们之所以有价值是因为底层代码执行复杂或重要的逻辑,从而提高了测试对回归的保护。它们之所以便宜是因为代码的协作者很少(理想情况下为零),从而降低了测试的维护成本。
Unit testing the top-left quadrant (domain model and algorithms) gives you the best return for your efforts. The resulting unit tests are highly valuable and cheap. They’re valuable because the underlying code carries out complex or important logic, thus increasing tests’ protection against regressions. And they’re cheap because the code has few collaborators (ideally, none), thus decreasing tests’ maintenance costs.
根本不应该测试琐碎的代码;这样的测试几乎没有任何价值。至于控制器,您应该将其作为一小部分总体集成测试的一部分进行简要测试(我在第 3 部分中介绍了这个主题)。
Trivial code shouldn’t be tested at all; such tests have a close-to-zero value. As for controllers, you should test them briefly as part of a much smaller set of the overarching integration tests (I cover this topic in part 3).
最成问题的代码类型是过于复杂的象限。单元测试很难,但没有测试覆盖率又太危险。这样的代码是许多人难以进行单元测试的主要原因之一。本章主要致力于如何避免这种困境。一般的想法是将过于复杂的代码分成两部分:算法和控制器(图 7.2),尽管实际实现有时可能会很棘手。
The most problematic type of code is the overcomplicated quadrant. It’s hard to unit test but too risky to leave without test coverage. Such code is one of the main reasons many people struggle with unit testing. This whole chapter is primarily devoted to how you can bypass this dilemma. The general idea is to split overcomplicated code into two parts: algorithms and controllers (figure 7.2), although the actual implementation can be tricky at times.
代码越重要或越复杂,需要的协作者就越少。
The more important or complex the code, the fewer collaborators it should have.
摆脱过于复杂的代码并仅对领域模型和算法进行单元测试是获得高价值、易于维护的测试套件的途径。使用这种方法,您不会获得 100% 的测试覆盖率,但您不需要这样做——100% 的覆盖率永远不应该是您的目标。您的目标是一个测试套件,其中每个测试都为项目增加了重要价值。重构或删除所有其他测试。不要让它们扩大您的测试套件的大小。
Getting rid of the overcomplicated code and unit testing only the domain model and algorithms is the path to a highly valuable, easily maintainable test suite. With this approach, you won’t have 100% test coverage, but you don’t need to—100% coverage shouldn’t ever be your goal. Your goal is a test suite where each test adds significant value to the project. Refactor or get rid of all other tests. Don’t allow them to inflate the size of your test suite.
Remember that it’s better to not write a test at all than to write a bad test.
当然,摆脱过于复杂的代码说起来容易做起来难。不过,还是有一些技巧可以帮助你做到这一点。我将首先解释这些技巧背后的理论,然后使用一个接近现实世界的例子来演示它们。
Of course, getting rid of overcomplicated code is easier said than done. Still, there are techniques that can help you do that. I’ll first explain the theory behind those techniques and then demonstrate them using a close-to-real-world example.
要拆分过于复杂的代码,您需要使用 Humble Object 设计模式。Gerard Meszaros 在他的书《xUnit 测试模式:重构测试代码》(Addison-Wesley,2007 年)中介绍了此模式,作为对抗代码耦合的方法之一,但它的应用范围要广泛得多。您很快就会明白为什么。
To split overcomplicated code, you need to use the Humble Object design pattern. This pattern was introduced by Gerard Meszaros in his book xUnit Test Patterns: Refactoring Test Code (Addison-Wesley, 2007) as one of the ways to battle code coupling, but it has a much broader application. You’ll see why shortly.
我们经常发现代码很难测试,因为它与框架依赖项耦合(见图7.3)。示例包括异步或多线程执行、用户界面、与进程外依赖项的通信等。
We often find that code is hard to test because it’s coupled to a framework dependency (see figure 7.3). Examples include asynchronous or multi-threaded execution, user interfaces, communication with out-of-process dependencies, and so on.
要测试此代码的逻辑,您需要从中提取出可测试的部分。结果,代码变成了可测试部分的一个薄而简陋的包装:它将将难以测试的依赖关系和新提取的组件放在一起,但其本身包含很少或根本不包含逻辑,因此不需要测试(图 7.4)。
To bring the logic of this code under test, you need to extract a testable part out of it. As a result, the code becomes a thin, humble wrapper around that testable part: it glues the hard-to-test dependency and the newly extracted component together, but itself contains little or no logic and thus doesn’t need to be tested (figure 7.4).
如果这种方法看起来很熟悉,那是因为你已经在这本书中看到过它了。事实上,六边形架构和功能架构都实现了这种精确的模式。你可能还记得前面几章的内容,六边形架构主张将业务逻辑与具有进程外依赖关系的通信分开。这就是域层和应用服务层分别负责的事情。
If this approach looks familiar, it’s because you already saw it in this book. In fact, both hexagonal and functional architectures implement this exact pattern. As you may remember from previous chapters, hexagonal architecture advocates for the separation of business logic and communications with out-of-process dependencies. This is what the domain and application services layers are responsible for, respectively.
功能架构更进一步,将业务逻辑与所有协作者(而不仅仅是进程外协作者)的通信分离开来。这就是功能架构如此易于测试的原因:其功能核心没有协作者。功能核心中的所有依赖项都是不可变的,这使其非常接近代码类型图上的纵轴(图 7.5)。
Functional architecture goes even further and separates business logic from communications with all collaborators, not just out-of-process ones. This is what makes functional architecture so testable: its functional core has no collaborators. All dependencies in a functional core are immutable, which brings it very close to the vertical axis on the types-of-code diagram (figure 7.5).
另一种看待谦卑对象模式的方式是将其视为遵守单一职责原则的一种手段,该原则规定每个类应该只承担单一职责。[ 1 ]此类职责之一始终是业务逻辑;该模式可用于将该逻辑与几乎所有内容隔离开来。
Another way to view the Humble Object pattern is as a means to adhere to the Single Responsibility principle, which states that each class should have only a single responsibility.[1] One such responsibility is always business logic; the pattern can be applied to segregate that logic from pretty much anything.
请参阅Robert C. Martin 和 Micah Martin 合著的《C# 中的敏捷原则、模式和实践》(Prentice Hall,2006 年)。
See Agile Principles, Patterns, and Practices in C# by Robert C. Martin and Micah Martin (Prentice Hall, 2006).
在我们的特定情况下,我们感兴趣的是业务逻辑和业务流程的分离。您可以从代码深度与代码宽度的角度来考虑这两个职责。您的代码可以是深度(复杂或重要)或宽度(与许多协作者合作),但绝不能两者兼而有之(图 7.6)。
In our particular situation, we are interested in the separation of business logic and orchestration. You can think of these two responsibilities in terms of code depth versus code width. Your code can be either deep (complex or important) or wide (work with many collaborators), but never both (figure 7.6).
我再怎么强调这种分离的重要性都不为过。事实上,许多众所周知的原则和模式都可以描述为 Humble Object 模式的一种形式:它们专门设计用于将复杂代码与执行编排的代码隔离开来。
I can’t stress enough how important this separation is. In fact, many well-known principles and patterns can be described as a form of the Humble Object pattern: they are designed specifically to segregate complex code from the code that does orchestration.
您已经看到了此模式与六边形和功能架构之间的关系。其他示例包括模型-视图-演示器 (MVP) 和模型-视图-控制器 (MVC) 模式。这两种模式可帮助您解耦业务逻辑(模型部分)、UI 关注点(视图)以及它们之间的协调(演示器或控制器)。演示器和控制器组件是不起眼的对象:它们将视图和模型粘合在一起。
You already saw the relationship between this pattern and hexagonal and functional architectures. Other examples include the Model-View-Presenter (MVP) and the Model-View-Controller (MVC) patterns. These two patterns help you decouple business logic (the Model part), UI concerns (the View), and the coordination between them (Presenter or Controller). The Presenter and Controller components are humble objects: they glue the view and the model together.
另一个例子是领域驱动设计中的聚合模式。[ 2 ]它的目标之一是通过将类分组为群集(聚合)来减少类之间的连接。类在这些群集内高度连接,但群集本身是松散耦合的。这样的结构减少了代码库中的总通信次数。连接性的减少反过来又提高了可测试性。
Another example is the Aggregate pattern from Domain-Driven Design.[2] One of its goals is to reduce connectivity between classes by grouping them into clusters—aggregates. The classes are highly connected inside those clusters, but the clusters themselves are loosely coupled. Such a structure decreases the total number of communications in the code base. The reduced connectivity, in turn, improves testability.
请参阅Eric Evans 所著的《领域驱动设计:解决软件核心的复杂性》(Addison-Wesley,2003 年)。
See Domain-Driven Design: Tackling Complexity in the Heart of Software by Eric Evans (Addison-Wesley, 2003).
请注意,提高可测试性并不是保持业务逻辑和业务流程分离的唯一原因。这种分离还有助于解决代码复杂性问题,这对于项目增长也至关重要,尤其是从长远来看。我个人一直觉得可测试的设计不仅可测试,而且易于维护,这真是令人着迷。
Note that improved testability is not the only reason to maintain the separation between business logic and orchestration. Such a separation also helps tackle code complexity, which is crucial for project growth, too, especially in the long run. I personally always find it fascinating how a testable design is not only testable but also easy to maintain.
在本节中,我将展示一个将过于复杂的代码拆分为算法和控制器的全面示例。您在上一章中看到了类似的示例,我们在其中讨论了基于输出的测试和功能架构。这一次,我将借助 Humble Object 模式将这种方法推广到所有企业级应用程序。我不仅会在本章中使用这个项目,还会在第 3 部分的后续章节中使用这个项目。
In this section, I’ll show a comprehensive example of splitting overcomplicated code into algorithms and controllers. You saw a similar example in the previous chapter, where we talked about output-based testing and functional architecture. This time, I’ll generalize this approach to all enterprise-level applications, with the help of the Humble Object pattern. I’ll use this project not only in this chapter but also in the subsequent chapters of part 3.
示例项目是一个处理用户注册的客户管理系统 (CRM)。所有用户都存储在数据库中。该系统目前仅支持一种用例:更改用户的电子邮件。此操作涉及三条业务规则:
The sample project is a customer management system (CRM) that handles user registrations. All users are stored in a database. The system currently supports only one use case: changing a user’s email. There are three business rules involved in this operation:
以下清单显示了 CRM 系统的初步实施。
The following listing shows the initial implementation of the CRM system.
公共类用户
{
公共 int UserId { 获取; 私人设置; }
公共字符串电子邮件 { 获取; 私人设置; }
公共 UserType 类型 { 获取; 私人设置; }
公共无效更改电子邮件(int userId,string newEmail)
{
对象[]数据 = 数据库.GetUserById(用户Id); 1
用户ID = 用户ID;
电子邮件 = (字符串)数据[1];
类型 = (用户类型)数据[2];
如果(电子邮件==新电子邮件)
返回;
对象[] companyData = 数据库.GetCompany(); 2
字符串 companyDomainName = (字符串)companyData[0];
int 员工数量 = (int)公司数据[1];
字符串 emailDomain = newEmail.Split('@')[1];
bool isEmailCorporate = emailDomain == companyDomainName;
用户类型新类型 = isEmailCorporate 3
? 用户类型.员工 3
: 用户类型.客户; 3
如果(类型!=新类型)
{
int delta = newType == UserType.Employee ? 1 : -1;
int 新数量 = 员工数量 + delta;
数据库.保存公司(新号码); 4
}
电子邮件=新电子邮件;
类型=新类型;
数据库.SaveUser(this); 5
MessageBus.SendEmailChangedMessage(UserId,newEmail); 6
}
}
公共枚举用户类型
{
客户= 1,
员工 = 2
}public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public void ChangeEmail(int userId, string newEmail)
{
object[] data = Database.GetUserById(userId); 1
UserId = userId;
Email = (string)data[1];
Type = (UserType)data[2];
if (Email == newEmail)
return;
object[] companyData = Database.GetCompany(); 2
string companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];
string emailDomain = newEmail.Split('@')[1];
bool isEmailCorporate = emailDomain == companyDomainName;
UserType newType = isEmailCorporate 3
? UserType.Employee 3
: UserType.Customer; 3
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
int newNumber = numberOfEmployees + delta;
Database.SaveCompany(newNumber); 4
}
Email = newEmail;
Type = newType;
Database.SaveUser(this); 5
MessageBus.SendEmailChangedMessage(UserId, newEmail); 6
}
}
public enum UserType
{
Customer = 1,
Employee = 2
}
该类User更改了用户的电子邮件。请注意,为了简洁起见,我省略了简单的验证,例如检查电子邮件的正确性和数据库中的用户存在性。让我们从代码类型图的角度分析一下这个实现。
The User class changes a user email. Note that, for brevity, I omitted simple validations such as checks for email correctness and user existence in the database. Let’s analyze this implementation from the perspective of the types-of-code diagram.
代码的复杂度并不太高。该ChangeEmail方法仅包含几个明确的决策点:是否将用户识别为员工或客户,以及如何更新公司的员工人数。尽管很简单,但这些决策很重要:它们是应用程序的核心业务逻辑。因此,该类在复杂性和领域重要性维度上得分很高。
The code’s complexity is not too high. The ChangeEmail method contains only a couple of explicit decision-making points: whether to identify the user as an employee or a customer, and how to update the company’s number of employees. Despite being simple, these decisions are important: they are the application’s core business logic. Hence, the class scores highly on the complexity and domain significance dimension.
另一方面,该类User有四个依赖项,其中两个是显式的,另外两个是隐式的。显式依赖项是userId和newEmail参数。不过,这些都是值,因此不计入类的协作者数量。隐式依赖项是Database和MessageBus。这两个是进程外协作者。正如我之前提到的,进程外协作者对于具有高领域重要性的代码来说是不可行的。因此,该类User在协作者维度上得分很高,这使得该类属于过于复杂的类别(图 7.7)。
On the other hand, the User class has four dependencies, two of which are explicit and the other two of which are implicit. The explicit dependencies are the userId and newEmail arguments. These are values, though, and thus don’t count toward the class’s number of collaborators. The implicit ones are Database and MessageBus. These two are out-of-process collaborators. As I mentioned earlier, out-of-process collaborators are a no-go for code with high domain significance. Hence, the User class scores highly on the collaborators dimension, which puts this class into the overcomplicated category (figure 7.7).
这种方法(即域类检索并将自身持久化到数据库)称为 Active Record 模式。它在简单或短期项目中运行良好,但随着代码库的增长,它往往无法扩展。原因恰恰在于这两个职责之间缺乏分离:业务逻辑和与进程外依赖项的通信。
This approach—when a domain class retrieves and persists itself to the database—is called the Active Record pattern. It works fine in simple or short-lived projects but often fails to scale as the code base grows. The reason is precisely this lack of separation between these two responsibilities: business logic and communication with out-of-process dependencies.
提高可测试性的常用方法是将隐式依赖项显式化:即引入Database和的接口MessageBus,将这些接口注入User,然后在测试中模拟它们。这种方法确实有帮助,这正是我们在上一章中为审计系统引入模拟实现时所做的。然而,这还不够。
The usual approach to improve testability is to make implicit dependencies explicit: that is, introduce interfaces for Database and MessageBus, inject those interfaces into User, and then mock them in tests. This approach does help, and that’s exactly what we did in the previous chapter when we introduced the implementation with mocks for the audit system. However, it’s not enough.
从代码类型图的角度来看,域模型是直接引用进程外依赖项还是通过接口引用依赖项并不重要。此类依赖项仍然是进程外的;它们是尚未进入内存的数据的代理。您仍然需要维护复杂的模拟机制才能测试此类类,这会增加测试的维护成本。此外,对数据库依赖项使用模拟会导致测试脆弱性(我们将在下一章中讨论这一点)。
From the perspective of the types-of-code diagram, it doesn’t matter if the domain model refers to out-of-process dependencies directly or via an interface. Such dependencies are still out-of-process; they are proxies to data that is not yet in memory. You still need to maintain complicated mock machinery in order to test such classes, which increases the tests’ maintenance costs. Moreover, using mocks for the database dependency would lead to test fragility (we’ll discuss this in the next chapter).
总体而言,领域模型完全不依赖进程外协作者(直接或间接(通过接口))会更简洁。这也是六边形架构所提倡的——领域模型不应负责与外部系统的通信。
Overall, it’s much cleaner for the domain model not to depend on out-of-process collaborators at all, directly or indirectly (via an interface). That’s what the hexagonal architecture advocates as well—the domain model shouldn’t be responsible for communications with external systems.
为了解决领域模型直接与外部系统通信的问题,我们需要将这一责任转移到另一个类,即一个不起眼的控制器(六边形架构分类法中的应用服务)。一般来说,领域类应该只依赖于进程内依赖项,例如其他领域类或普通值。以下是该应用服务的第一个版本。
To overcome the problem of the domain model directly communicating with external systems, we need to shift this responsibility to another class, a humble controller (an application service, in the hexagonal architecture taxonomy). As a general rule, domain classes should only depend on in-process dependencies, such as other domain classes, or plain values. Here’s what the first version of that application service looks like.
公共类用户控制器
{
私有只读数据库 _database = new Database();
私有只读MessageBus _messageBus = new MessageBus();
公共无效更改电子邮件(int userId,string newEmail)
{
对象[] 数据 = _database.GetUserById(用户Id);
字符串电子邮件 = (字符串)数据[1];
用户类型类型 = (用户类型)数据[2];
var 用户 = 新用户(用户 ID,电子邮件,类型);
对象[] companyData = _database.GetCompany();
字符串 companyDomainName = (字符串)companyData[0];
int 员工数量 = (int)公司数据[1];
int newNumberOfEmployees = 用户.ChangeEmail(
新电子邮件、公司域名、员工人数);
_数据库.保存公司(新员工数量);
_数据库.保存用户(用户);
_messageBus.SendEmailChangedMessage(用户ID,新电子邮件);
}
}public class UserController
{
private readonly Database _database = new Database();
private readonly MessageBus _messageBus = new MessageBus();
public void ChangeEmail(int userId, string newEmail)
{
object[] data = _database.GetUserById(userId);
string email = (string)data[1];
UserType type = (UserType)data[2];
var user = new User(userId, email, type);
object[] companyData = _database.GetCompany();
string companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];
int newNumberOfEmployees = user.ChangeEmail(
newEmail, companyDomainName, numberOfEmployees);
_database.SaveCompany(newNumberOfEmployees);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
}
}
这是一次很好的尝试;应用服务帮助减轻了类中进程外依赖项的工作负担User。但这种实现存在一些问题:
This is a good first try; the application service helped offload the work with out-of-process dependencies from the User class. But there are some issues with this implementation:
该类User变得非常容易测试,因为它不再需要与进程外依赖项进行通信。事实上,它没有任何协作者——无论是否在进程外。以下是 的新版本User方法ChangeEmail:
The User class has become quite easy to test because it no longer has to communicate with out-of-process dependencies. In fact, it has no collaborators whatsoever—out-of-process or not. Here’s the new version of User’s ChangeEmail method:
公共 int ChangeEmail(字符串 newEmail,
字符串公司域名,整数员工数量)
{
如果(电子邮件==新电子邮件)
返回员工人数;
字符串 emailDomain = newEmail.Split('@')[1];
bool isEmailCorporate = emailDomain == companyDomainName;
用户类型新类型 = isEmailCorporate
? 用户类型.员工
:用户类型.客户;
如果(类型!=新类型)
{
int delta = newType == UserType.Employee ? 1 : -1;
int 新数量 = 员工数量 + delta;
员工人数 = 新人数;
}
电子邮件=新电子邮件;
类型=新类型;
返回员工人数;
}public int ChangeEmail(string newEmail,
string companyDomainName, int numberOfEmployees)
{
if (Email == newEmail)
return numberOfEmployees;
string emailDomain = newEmail.Split('@')[1];
bool isEmailCorporate = emailDomain == companyDomainName;
UserType newType = isEmailCorporate
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
int newNumber = numberOfEmployees + delta;
numberOfEmployees = newNumber;
}
Email = newEmail;
Type = newType;
return numberOfEmployees;
}
图 7.8显示了User和UserController当前在图中的位置。User已移至域模型象限,靠近垂直轴,因为它不再需要处理协作者。UserController问题更多。虽然我已将其放入控制器象限,但它几乎越界进入过于复杂的代码,因为它包含相当复杂的逻辑。
Figure 7.8 shows where User and UserController currently stand in our diagram. User has moved to the domain model quadrant, close to the vertical axis, because it no longer has to deal with collaborators. UserController is more problematic. Although I’ve put it into the controllers quadrant, it almost crosses the boundary into overcomplicated code because it contains logic that is quite complex.
为了将UserController其牢牢地放入控制器象限,我们需要从中提取重构逻辑。如果您使用对象关系映射 (ORM) 库将数据库映射到域模型,那么这将是将重构逻辑归因于此的好地方。每个 ORM 库都有一个专用的位置,您可以在其中指定应如何将数据库表映射到域类,例如这些域类之上的属性、XML 文件或具有流畅映射的文件。
To put UserController firmly into the controllers quadrant, we need to extract the reconstruction logic from it. If you use an object-relational mapping (ORM) library to map the database into the domain model, that would be a good place to which to attribute the reconstruction logic. Each ORM library has a dedicated place where you can specify how your database tables should be mapped to domain classes, such as attributes on top of those domain classes, XML files, or files with fluent mappings.
如果您不想或不能使用 ORM,请在域模型中创建一个工厂,该工厂将使用原始数据库数据实例化域类。此工厂可以是单独的类,或者在更简单的情况下,可以是现有域类中的静态方法。我们的示例应用程序中的重建逻辑并不是太复杂,但最好将这些事情分开,因此我将其放在一个单独的UserFactory类中,如下面的清单所示。
If you don’t want to or can’t use an ORM, create a factory in the domain model that will instantiate the domain classes using raw database data. This factory can be a separate class or, for simpler cases, a static method in the existing domain classes. The reconstruction logic in our sample application is not too complicated, but it’s good to keep such things separated, so I’m putting it in a separate UserFactory class as shown in the following listing.
公共类用户工厂
{
公共静态用户创建(对象[]数据)
{
前提条件.要求(数据.长度 >= 3);
int id = (int)数据[0];
字符串电子邮件 = (字符串)数据[1];
用户类型类型 = (用户类型)数据[2];
返回新用户(id,电子邮件,类型);
}
}public class UserFactory
{
public static User Create(object[] data)
{
Precondition.Requires(data.Length >= 3);
int id = (int)data[0];
string email = (string)data[1];
UserType type = (UserType)data[2];
return new User(id, email, type);
}
}
此代码现在与所有协作者完全隔离,因此易于测试。请注意,我在此方法中设置了一个保护措施:要求数据数组中至少有三个元素。Precondition是一个简单的自定义类,如果布尔参数为 false,则会引发异常。此类的原因是代码更简洁,条件反转:肯定语句比否定语句更易读。在我们的示例中,data.Length >= 3要求比
This code is now fully isolated from all collaborators and therefore easily testable. Notice that I’ve put a safeguard in this method: a requirement to have at least three elements in the data array. Precondition is a simple custom class that throws an exception if the Boolean argument is false. The reason for this class is the more succinct code and the condition inversion: affirmative statements are more readable than negative ones. In our example, the data.Length >= 3 requirement reads better than
如果 (数据.长度 < 3)
抛出新的异常();if (data.Length < 3)
throw new Exception();
请注意,虽然此重建逻辑有些复杂,但它不具有领域意义:它与客户端更改用户电子邮件的目标没有直接关系。这是我在前几章中引用的实用程序代码的示例。
Note that while this reconstruction logic is somewhat complex, it doesn’t have domain significance: it isn’t directly related to the client’s goal of changing the user email. It’s an example of the utility code I refer to in previous chapters.
鉴于该方法中只有一个分支点,重建逻辑怎么会这么复杂呢UserFactory.Create()?正如我在第 1 章中提到的,代码使用的底层库中可能存在大量隐藏的分支点,因此出现问题的可能性很大。该方法就是这种情况UserFactory.Create()。
How is the reconstruction logic complex, given that there’s only a single branching point in the UserFactory.Create() method? As I mentioned in chapter 1, there could be a lot of hidden branching points in the underlying libraries used by the code and thus a lot of potential for something to go wrong. This is exactly the case for the UserFactory.Create() method.
通过索引 () 引用数组元素data[0]需要 .NET Framework 做出内部决策,决定访问哪个数据元素。从object到int或 的转换也是如此string。在内部,.NET Framework 决定是抛出强制转换异常还是允许转换继续。尽管缺少决策点,但所有这些隐藏的分支都使重构逻辑值得测试。
Referring to an array element by index (data[0]) entails an internal decision made by the .NET Framework as to what data element to access. The same is true for the conversion from object to int or string. Internally, the .NET Framework decides whether to throw a cast exception or allow the conversion to proceed. All these hidden branches make the reconstruction logic test-worthy, despite the lack of decision points in it.
再次查看控制器中的此代码:
Look at this code in the controller once again:
对象[] companyData = _database.GetCompany();
字符串 companyDomainName = (字符串)companyData[0];
int 员工数量 = (int)公司数据[1];
int newNumberOfEmployees = 用户.ChangeEmail(
新电子邮件、公司域名、员工人数);object[] companyData = _database.GetCompany();
string companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];
int newNumberOfEmployees = user.ChangeEmail(
newEmail, companyDomainName, numberOfEmployees);
返回更新的员工人数的尴尬User是责任错位的标志,而责任错位本身又是抽象缺失的标志。为了解决这个问题,我们需要引入另一个领域类,Company将公司相关的逻辑和数据捆绑在一起,如下面的清单所示。
The awkwardness of returning an updated number of employees from User is a sign of a misplaced responsibility, which itself is a sign of a missing abstraction. To fix this, we need to introduce another domain class, Company, that bundles the company-related logic and data together, as shown in the following listing.
公开课 公司
{
公共字符串域名 { 获取; 私有设置; }
公共 int NumberOfEmployees { 获取; 私人集合; }
公共无效ChangeNumberOfEmployees(int delta)
{
前提条件.需要(员工数量 + delta >= 0);
员工人数+=增量;
}
公共 bool IsEmailCorporate(字符串电子邮件)
{
字符串 emailDomain = email.Split('@')[1];
返回 emailDomain == DomainName;
}
}public class Company
{
public string DomainName { get; private set; }
public int NumberOfEmployees { get; private set; }
public void ChangeNumberOfEmployees(int delta)
{
Precondition.Requires(NumberOfEmployees + delta >= 0);
NumberOfEmployees += delta;
}
public bool IsEmailCorporate(string email)
{
string emailDomain = email.Split('@')[1];
return emailDomain == DomainName;
}
}
这个类中有两种方法:ChangeNumberOfEmployees()和IsEmailCorporate()。这些方法有助于遵守我在第 5 章中提到的“告诉而不问”原则。该原则主张将数据和对数据的操作捆绑在一起。一个User实例将告诉公司更改其员工人数或确定特定电子邮件是否属于公司;它不会要求提供原始数据并自行完成所有工作。
There are two methods in this class: ChangeNumberOfEmployees() and IsEmailCorporate(). These methods help adhere to the tell-don’t-ask principle I mentioned in chapter 5. This principle advocates for bundling together data and operations on that data. A User instance will tell the company to change its number of employees or figure out whether a particular email is corporate; it won’t ask for the raw data and do everything on its own.
还有一个新CompanyFactory类,负责重建Company对象,类似于UserFactory。这是控制器现在的样子。
There’s also a new CompanyFactory class, which is responsible for the reconstruction of Company objects, similar to UserFactory. This is how the controller now looks.
公共类用户控制器
{
私有只读数据库 _database = new Database();
私有只读MessageBus _messageBus = new MessageBus();
公共无效更改电子邮件(int userId,string newEmail)
{
对象[] 用户数据 = _database.GetUserById(用户Id);
用户用户 = UserFactory.创建(用户数据);
对象[] companyData = _database.GetCompany();
公司公司 = CompanyFactory.Create(companyData);
用户.更改电子邮件(新电子邮件,公司);
_数据库.保存公司(公司);
_数据库.保存用户(用户);
_messageBus.SendEmailChangedMessage(用户ID,新电子邮件);
}
}public class UserController
{
private readonly Database _database = new Database();
private readonly MessageBus _messageBus = new MessageBus();
public void ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
}
}
这是User课程。
And here’s the User class.
公共类用户
{
公共 int UserId { 获取; 私人设置; }
公共字符串电子邮件 { 获取; 私人设置; }
公共 UserType 类型 { 获取; 私人设置; }
公共无效更改电子邮件(字符串新电子邮件,公司公司)
{
如果(电子邮件==新电子邮件)
返回;
用户类型新类型 = 公司.IsEmailCorporate(新电子邮件)
? 用户类型.员工
:用户类型.客户;
如果(类型!=新类型)
{
int delta = newType == UserType.Employee ? 1 : -1;
公司.改变员工人数(delta);
}
电子邮件=新电子邮件;
类型=新类型;
}
}public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public void ChangeEmail(string newEmail, Company company)
{
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
}
Email = newEmail;
Type = newType;
}
}
注意,删除错误的责任后,代码变得User更简洁。它不再操作公司数据,而是接受一个Company实例,并将两项重要工作委托给该实例:确定电子邮件是否属于公司,以及更改公司员工人数。
Notice how the removal of the misplaced responsibility made User much cleaner. Instead of operating on company data, it accepts a Company instance and delegates two important pieces of work to that instance: determining whether an email is corporate and changing the number of employees in the company.
图 7.9显示了每个类在图中的位置。工厂和两个领域类都位于领域模型和算法象限中。User已向右移动,因为它现在有一个协作者,Company而以前没有。这降低了User可测试性,但影响不大。
Figure 7.9 shows where each class stands in the diagram. The factories and both domain classes reside in the domain model and algorithms quadrant. User has moved to the right because it now has one collaborator, Company, whereas previously it had none. That has made User less testable, but not much.
UserController现在稳固地站在控制器象限中,因为它的所有复杂性都已转移到工厂。此类的唯一职责是将所有协作方粘合在一起。
UserController now firmly stands in the controllers quadrant because all of its complexity has moved to the factories. The only thing this class is responsible for is gluing together all the collaborating parties.
请注意此实现与上一章中的功能架构之间的相似之处。审计系统中的功能核心和此 CRM 中的域层(User和Company类)均不与进程外依赖项进行通信。在这两种实现中,应用服务层都负责此类通信:它从文件系统或数据库获取原始数据,将该数据传递给无状态算法或域模型,然后将结果持久化回数据存储。
Note the similarities between this implementation and the functional architecture from the previous chapter. Neither the functional core in the audit system nor the domain layer in this CRM (the User and Company classes) communicates with out-of-process dependencies. In both implementations, the application services layer is responsible for such communication: it gets the raw data from the filesystem or from the database, passes that data to stateless algorithms or the domain model, and then persists the results back to the data storage.
两种实现之间的区别在于它们对副作用的处理。功能核心不会产生任何副作用。CRM 的域模型会产生副作用,但所有这些副作用都以更改的用户电子邮件和员工人数的形式保留在域模型内部。只有当控制器将User和Company对象持久保存在数据库中时,副作用才会跨越域模型的边界。
The difference between the two implementations is in their treatment of side effects. The functional core doesn’t incur any side effects whatsoever. The CRM’s domain model does, but all those side effects remain inside the domain model in the form of the changed user email and the number of employees. The side effects only cross the domain model’s boundary when the controller persists the User and Company objects in the database.
所有副作用直到最后一刻都包含在内存中,这一事实大大提高了可测试性。您的测试不需要检查进程外依赖关系,也不需要诉诸基于通信的测试。所有验证都可以使用基于输出和基于状态的内存对象测试来完成。
The fact that all side effects are contained in memory until the very last moment improves testability a lot. Your tests don’t need to examine out-of-process dependencies, nor do they need to resort to communication-based testing. All the verification can be done using output-based and state-based testing of objects in memory.
现在我们已经在 Humble Object 模式的帮助下完成了重构,让我们分析一下项目的哪些部分属于哪个代码类别,以及应该如何测试这些部分。表 7.1显示了示例项目中的所有代码,按代码类型图中的位置分组。
Now that we’ve completed the refactoring with the help of the Humble Object pattern, let’s analyze which parts of the project fall into which code category and how those parts should be tested. Table 7.1 shows all the code from the sample project grouped by position in the types-of-code diagram.
|
合作者寥寥 Few collaborators |
许多合作者 Many collaborators |
|
|---|---|---|
| 高复杂性或领域重要性 | User 中的 ChangeEmail(newEmail, company);Company 中的 ChangeNumberOfEmployees(delta) 和 IsEmailCorporate(email);以及 UserFactory 和 CompanyFactory 中的 Create(data) | |
| 低复杂度和领域重要性 | 用户和公司的构造函数 | UserController 中的 ChangeEmail(userId,newEmail) |
通过完全分离业务逻辑和业务流程,可以轻松决定对代码库的哪些部分进行单元测试。
With the full separation of business logic and orchestration at hand, it’s easy to decide which parts of the code base to unit test.
表 7.1左上象限中的测试方法在成本效益方面提供了最佳结果。代码的高复杂性或领域重要性保证了对回归的极大保护,而协作者较少则确保了最低的维护成本。这是一个如何User测试的示例:
Testing methods in the top-left quadrant in table 7.1 provides the best results in cost-benefit terms. The code’s high complexity or domain significance guarantees great protection against regressions, while having few collaborators ensures the lowest maintenance costs. This is an example of how User could be tested:
[事实]
公共无效 Changing_email_from_non_corporate_to_corporate()
{
var company = new Company("mycorp.com", 1);
var sut = 新用户(1,“user@gmail.com”,UserType.Customer);
sut.ChangeEmail("new@mycorp.com", 公司);
断言.等于(2,公司.员工人数);
断言.Equal("new@mycorp.com", sut.Email);
断言.等于(用户类型.员工,sut.类型);
}[Fact]
public void Changing_email_from_non_corporate_to_corporate()
{
var company = new Company("mycorp.com", 1);
var sut = new User(1, "user@gmail.com", UserType.Customer);
sut.ChangeEmail("new@mycorp.com", company);
Assert.Equal(2, company.NumberOfEmployees);
Assert.Equal("new@mycorp.com", sut.Email);
Assert.Equal(UserType.Employee, sut.Type);
}
为了实现全面覆盖,您还需要另外三项这样的测试:
To achieve full coverage, you’d need another three such tests:
公共无效 Changing_email_from_corporate_to_non_corporate() public void Changing_email_without_changing_user_type() 公共无效将电子邮件更改为同一个电子邮件()
public void Changing_email_from_corporate_to_non_corporate() public void Changing_email_without_changing_user_type() public void Changing_email_to_the_same_one()
其他三个类的测试会更短,并且您可以使用参数化测试将多个测试用例组合在一起:
Tests for the other three classes would be even shorter, and you could use parameterized tests to group several test cases together:
[InlineData("mycorp.com", "email@mycorp.com", true)]
[InlineData("mycorp.com", "email@gmail.com", false)]
[理论]
public void 区分公司电子邮件和非公司电子邮件(
字符串域,字符串电子邮件,布尔预期结果)
{
var sut = 新公司(域, 0);
bool isEmailCorporate = sut.IsEmailCorporate(电子邮件);
断言.等于(预期结果,是电子邮件公司);
}[InlineData("mycorp.com", "email@mycorp.com", true)]
[InlineData("mycorp.com", "email@gmail.com", false)]
[Theory]
public void Differentiates_a_corporate_email_from_non_corporate(
string domain, string email, bool expectedResult)
{
var sut = new Company(domain, 0);
bool isEmailCorporate = sut.IsEmailCorporate(email);
Assert.Equal(expectedResult, isEmailCorporate);
}
复杂度低、协作者少的代码(表 7.1中的左下象限)由User和中的构造函数表示Company,例如
Code with low complexity and few collaborators (bottom-left quadrant in table 7.1) is represented by the constructors in User and Company, such as
公共用户(int userId,string email,UserType 类型)
{
用户ID = 用户ID;
电子邮件=电子邮件;
类型=类型;
}public User(int userId, string email, UserType type)
{
UserId = userId;
Email = email;
Type = type;
}
这些构造函数很琐碎,不值得付出努力。由此产生的测试无法提供足够好的保护来防止回归。
These constructors are trivial and aren’t worth the effort. The resulting tests wouldn’t provide great enough protection against regressions.
重构已经消除了所有复杂度高且协作者数量多的代码(表 7.1中的右上象限),因此我们在那里也没有什么可测试的。至于控制器象限(表 7.1中的右下象限),我们将在下一章讨论如何测试它。
The refactoring has eliminated all code with high complexity and a large number of collaborators (top-right quadrant in table 7.1), so we have nothing to test there, either. As for the controllers quadrant (bottom-right in table 7.1), we’ll discuss testing it in the next chapter.
让我们看看一种特殊的分支点——先决条件——看看是否应该测试它们。例如,Company再次查看此方法:
Let’s take a look at a special kind of branching points—preconditions—and see whether you should test them. For example, look at this method from Company once again:
公共无效ChangeNumberOfEmployees(int delta)
{
前提条件.需要(员工数量 + delta >= 0);
员工人数+=增量;
}public void ChangeNumberOfEmployees(int delta)
{
Precondition.Requires(NumberOfEmployees + delta >= 0);
NumberOfEmployees += delta;
}
它有一个前提条件,规定公司员工人数永远不能为负数。这个前提条件是一种仅在特殊情况下激活的保护措施。这种特殊情况通常是由于错误造成的。员工人数低于零的唯一可能原因是代码中有错误。该保护措施为您的软件提供了一种快速失败的机制,并防止错误扩散并持久保存在数据库中,而数据库中的错误处理将变得更加困难。您应该测试这样的前提条件吗?换句话说,这样的测试是否足够有价值,值得放在测试套件中?
It has a precondition stating that the number of employees in the company should never become negative. This precondition is a safeguard that’s activated only in exceptional cases. Such exceptional cases are usually the result of bugs. The only possible reason for the number of employees to go below zero is if there’s an error in code. The safeguard provides a mechanism for your software to fail fast and to prevent the error from spreading and being persisted in the database, where it would be much harder to deal with. Should you test such preconditions? In other words, would such tests be valuable enough to have in the test suite?
这里没有硬性规定,但我建议的一般准则是测试所有具有领域意义的先决条件。对员工人数非负的要求就是这样一个先决条件。它是Company类的不变量的一部分:应始终保持为真的条件。但不要花时间测试不具有领域意义的先决条件。例如,UserFactory其方法中有以下保障措施Create:
There’s no hard rule here, but the general guideline I recommend is to test all preconditions that have domain significance. The requirement for the non-negative number of employees is such a precondition. It’s part of the Company class’s invariants: conditions that should be held true at all times. But don’t spend time testing preconditions that don’t have domain significance. For example, UserFactory has the following safeguard in its Create method:
公共静态用户创建(对象[]数据)
{
前提条件.要求(数据.长度 >= 3);
/* 从数据中提取 ID、电子邮件和类型 */
}public static User Create(object[] data)
{
Precondition.Requires(data.Length >= 3);
/* Extract id, email, and type out of data */
}
这个先决条件没有任何领域意义,因此测试它的价值不大。
There’s no domain meaning to this precondition and therefore not much value in testing it.
处理条件逻辑并同时保持域层不受进程外协作者的影响通常很棘手,并且需要权衡。在本节中,我将展示这些权衡是什么以及如何决定在您自己的项目中选择哪一个。
Handling conditional logic and simultaneously maintaining the domain layer free of out-of-process collaborators is often tricky and involves trade-offs. In this section, I’ll show what those trade-offs are and how to decide which of them to choose in your own project.
当业务操作分为三个不同的阶段时,业务逻辑和业务流程之间的分离效果最好:
The separation between business logic and orchestration works best when a business operation has three distinct stages:
不过,在很多情况下,这些阶段并不那么明确。正如我们在第 6 章中讨论的那样,您可能需要根据决策过程的中间结果从进程外依赖项中查询其他数据(图 7.11)。写入进程外依赖项通常也取决于该结果。
There are a lot of situations where these stages aren’t as clearcut, though. As we discussed in chapter 6, you might need to query additional data from an out-of-process dependency based on an intermediate result of the decision-making process (figure 7.11). Writing to the out-of-process dependency often depends on that result, too.
As also discussed in the previous chapter, you have three options in such a situation:
挑战在于平衡以下三个属性:
The challenge is to balance the following three attributes:
每个选项仅提供三个属性中的两个(图 7.12):
Each option only gives you two out of the three attributes (figure 7.12):
在大多数软件项目中,性能非常重要,因此第一种方法(将外部读写推到业务操作的边缘)是不可能的。第二种选择(将进程外依赖项注入域模型)会将大部分代码带入代码类型图上过于复杂的象限。这正是我们重构初始 CRM 实现时所避免的。我建议您避免这种方法:这样的代码不再保留业务逻辑与进程外依赖项通信之间的分离,因此变得更加难以测试和维护。
In most software projects, performance is important, so the first approach (pushing external reads and writes to the edges of a business operation) is out of the question. The second option (injecting out-of-process dependencies into the domain model) brings most of your code into the overcomplicated quadrant on the types-of-code diagram. This is exactly what we refactored the initial CRM implementation away from. I recommend that you avoid this approach: such code no longer preserves the separation between business logic and communication with out-of-process dependencies and thus becomes much harder to test and maintain.
这就剩下第三个选项:将决策过程拆分成更小的步骤。使用这种方法,您必须使控制器更加复杂,这也会使它们更接近过于复杂的象限。但有办法缓解这个问题。虽然您很少能够像我们之前在示例项目中所做的那样将所有复杂性从控制器中剔除,但您可以让这种复杂性保持可控。
That leaves you with the third option: splitting the decision-making process into smaller steps. With this approach, you will have to make your controllers more complex, which will also push them closer to the overcomplicated quadrant. But there are ways to mitigate this problem. Although you will rarely be able to factor all the complexity out of controllers as we did previously in the sample project, you can keep that complexity manageable.
缓解控制器复杂性增长的第一种方法是使用 CanExecute/Execute 模式,这有助于避免业务逻辑从域模型泄漏到控制器。最好用一个例子来解释这个模式,所以让我们扩展我们的示例项目。
The first way to mitigate the growth of the controllers’ complexity is to use the CanExecute/Execute pattern, which helps avoid leaking of business logic from the domain model to controllers. This pattern is best explained with an example, so let’s expand on our sample project.
假设用户只能在确认后才能更改其电子邮件。如果用户在确认后尝试更改电子邮件,则应向他们显示一条错误消息。为了满足这一新要求,我们将向该类添加一个新属性User。
Let’s say that a user can change their email only until they confirm it. If a user tries to change the email after the confirmation, they should be shown an error message. To accommodate this new requirement, we’ll add a new property to the User class.
公共类用户
{
公共 int UserId { 获取; 私人设置; }
公共字符串电子邮件 { 获取; 私人设置; }
公共 UserType 类型 { 获取; 私人设置; }
公共 bool IsEmailConfirmed 1
{ 获取;私人设置;} 1
/* ChangeEmail(newEmail, company) 方法 */
}public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public bool IsEmailConfirmed 1
{ get; private set; } 1
/* ChangeEmail(newEmail, company) method */
}
有两种方式可以放置此检查。首先,你可以将其放在 的User方法中ChangeEmail:
There are two options for where to put this check. First, you could put it in User’s ChangeEmail method:
公共字符串 ChangeEmail(字符串 newEmail,公司公司)
{
如果(IsEmailConfirmed)
返回“无法更改已确认的电子邮件”;
/* 方法的其余部分 */
}public string ChangeEmail(string newEmail, Company company)
{
if (IsEmailConfirmed)
return "Can't change a confirmed email";
/* the rest of the method */
}
然后,您可以让控制器根据此方法的输出返回错误或产生所有必要的副作用。
Then you could make the controller either return an error or incur all necessary side effects, depending on this method’s output.
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
object[] userData = _database.GetUserById(userId); 1
用户 user = UserFactory.Create(userData); 1
1
object[] companyData = _database.GetCompany(); 1
公司 company = CompanyFactory.Create(companyData); 1
字符串错误 = 用户.ChangeEmail(newEmail,company); 2
如果(错误!= null) 3
返回错误; 3
3
_database.SaveCompany(company); 3
_database.SaveUser(用户); 3
_messageBus.SendEmailChangedMessage(userId,newEmail); 3
3
返回“OK”; 3
}public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId); 1
User user = UserFactory.Create(userData); 1
1
object[] companyData = _database.GetCompany(); 1
Company company = CompanyFactory.Create(companyData); 1
string error = user.ChangeEmail(newEmail, company); 2
if (error != null) 3
return error; 3
3
_database.SaveCompany(company); 3
_database.SaveUser(user); 3
_messageBus.SendEmailChangedMessage(userId, newEmail); 3
3
return "OK"; 3
}
这种实现方式让控制器无需做决策,但这样做的代价是性能下降。Company实例会无条件地从数据库中检索,即使电子邮件已确认,因此无法更改。这是将所有外部读写操作推到业务操作边缘的一个例子。
This implementation keeps the controller free of decision-making, but it does so at the expense of a performance drawback. The Company instance is retrieved from the database unconditionally, even when the email is confirmed and thus can’t be changed. This is an example of pushing all external reads and writes to the edges of a business operation.
我不认为if分析error字符串的新语句会增加复杂性,因为它属于执行阶段;它不是决策过程的一部分。所有决策都由类做出User,控制器只是根据这些决策采取行动。
I don’t consider the new if statement analyzing the error string an increase in complexity because it belongs to the acting phase; it’s not part of the decision-making process. All the decisions are made by the User class, and the controller merely acts on those decisions.
第二种选择是将检查IsEmailConfirmed移至User控制器。
The second option is to move the check for IsEmailConfirmed from User to the controller.
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
对象[] 用户数据 = _database.GetUserById(用户Id);
用户用户 = UserFactory.创建(用户数据);
if (user.IsEmailConfirmed) 1
return "无法更改已确认的电子邮件"; 1
对象[] companyData = _database.GetCompany();
公司公司 = CompanyFactory.Create(companyData);
用户.更改电子邮件(新电子邮件,公司);
_数据库.保存公司(公司);
_数据库.保存用户(用户);
_messageBus.SendEmailChangedMessage(用户ID,新电子邮件);
返回“OK”;
}public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
if (user.IsEmailConfirmed) 1
return "Can't change a confirmed email"; 1
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
return "OK";
}
通过这种实现,性能保持不变:Company只有在确定可以更改电子邮件后才从数据库检索实例。但现在决策过程分为两部分:
With this implementation, the performance stays intact: the Company instance is retrieved from the database only after it is certain that the email can be changed. But now the decision-making process is split into two parts:
现在,无需先验证标志即可更改电子邮件IsEmailConfirmed,这会降低域模型的封装性。这种碎片化阻碍了业务逻辑和业务流程之间的分离,并使控制器更接近过于复杂的危险区域。
Now it’s also possible to change the email without verifying the IsEmailConfirmed flag first, which diminishes the domain model’s encapsulation. Such fragmentation hinders the separation between business logic and orchestration and moves the controller closer to the overcomplicated danger zone.
User为了防止这种碎片化,您可以在、中引入一个新方法CanChangeEmail(),并将其成功执行作为更改电子邮件的先决条件。以下清单中的修改版本遵循 CanExecute/Execute 模式。
To prevent this fragmentation, you can introduce a new method in User, CanChangeEmail(), and make its successful execution a precondition for changing an email. The modified version in the following listing follows the CanExecute/Execute pattern.
公共字符串 CanChangeEmail()
{
如果(IsEmailConfirmed)
返回“无法更改已确认的电子邮件”;
返回空值;
}
公共无效更改电子邮件(字符串新电子邮件,公司公司)
{
前提条件.需要(CanChangeEmail()==null);
/* 方法的其余部分 */
}public string CanChangeEmail()
{
if (IsEmailConfirmed)
return "Can't change a confirmed email";
return null;
}
public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
/* the rest of the method */
}
这种方法有两个重要的好处:
This approach provides two important benefits:
此模式可帮助您整合领域层中的所有决策。控制器不再具有不检查电子邮件确认的选项,这实质上消除了该控制器的新决策点。因此,尽管控制器仍包含if调用的语句CanChangeEmail(),但您无需测试该if语句。在类本身中对先决条件进行单元测试User就足够了。
This pattern helps you to consolidate all decisions in the domain layer. The controller no longer has an option not to check for the email confirmation, which essentially eliminates the new decision-making point from that controller. Thus, although the controller still contains the if statement calling CanChangeEmail(), you don’t need to test that if statement. Unit testing the precondition in the User class itself is enough.
为了简单起见,我使用string来表示错误。在实际项目中,您可能希望引入自定义Result类来指示操作的成功或失败。
For simplicity’s sake, I’m using a string to denote an error. In a real-world project, you may want to introduce a custom Result class to indicate the success or failure of an operation.
有时很难推断出哪些步骤导致域模型处于当前状态。不过,了解这些步骤可能很重要,因为您需要告知外部系统应用程序中到底发生了什么。将这个责任放在控制器上会使它们变得更加复杂。为了避免这种情况,您可以跟踪域模型中的重要更改,然后在业务操作完成后将这些更改转换为对进程外依赖项的调用。域事件可帮助您实现此类跟踪。
It’s sometimes hard to deduct what steps led the domain model to the current state. Still, it might be important to know these steps because you need to inform external systems about what exactly has happened in your application. Putting this responsibility on the controllers would make them more complicated. To avoid that, you can track important changes in the domain model and then convert those changes into calls to out-of-process dependencies after the business operation is complete. Domain events help you implement such tracking.
领域事件描述了应用程序中对领域专家有意义的事件。对领域专家的意义是领域事件与常规事件(如按钮点击)的区别。领域事件通常用于通知外部应用程序系统中发生的重要变化。
A domain event describes an event in the application that is meaningful to domain experts. The meaningfulness for domain experts is what differentiates domain events from regular events (such as button clicks). Domain events are often used to inform external applications about important changes that have happened in your system.
我们的 CRM 也有一个跟踪要求:它必须通过向消息总线发送消息来通知外部系统有关用户电子邮件的更改。当前的实现在通知功能方面存在缺陷:即使电子邮件没有更改,它也会发送消息,如下面的清单所示。
Our CRM has a tracking requirement, too: it has to notify external systems about changed user emails by sending messages to the message bus. The current implementation has a flaw in the notification functionality: it sends messages even when the email is not changed, as shown in the following listing.
// 用户
公共无效更改电子邮件(字符串新电子邮件,公司公司)
{
前提条件.需要(CanChangeEmail()==null);
如果 (电子邮件 == 新电子邮件) 1
返回;
/* 方法的其余部分 */
}
// 控制器
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
/* 准备 */
用户.更改电子邮件(新电子邮件,公司);
_数据库.保存公司(公司);
_数据库.保存用户(用户);
_messageBus.SendEmailChangedMessage( 2
用户 ID,新电子邮件); 2
返回“OK”;
}// User
public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
if (Email == newEmail) 1
return;
/* the rest of the method */
}
// Controller
public string ChangeEmail(int userId, string newEmail)
{
/* preparations */
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage( 2
userId, newEmail); 2
return "OK";
}
您可以通过将检查电子邮件是否相同的功能移至控制器来解决此错误,但话又说回来,业务逻辑碎片化也存在问题。而且您无法进行此检查,CanChangeEmail()因为如果新电子邮件与旧电子邮件相同,应用程序不应返回错误。
You could resolve this bug by moving the check for email sameness to the controller, but then again, there are issues with the business logic fragmentation. And you can’t put this check to CanChangeEmail() because the application shouldn’t return an error if the new email is the same as the old one.
请注意,此特定检查可能不会引入太多业务逻辑碎片,因此我个人认为如果控制器包含该检查,它不会过于复杂。但您可能会发现自己处于更困难的情况,在这种情况下,很难阻止您的应用程序对进程外依赖项进行不必要的调用而不将这些依赖项传递给域模型,从而使该域模型过于复杂。防止这种过度复杂化的唯一方法是使用域事件。
Note that this particular check probably doesn’t introduce too much business logic fragmentation, so I personally wouldn’t consider the controller overcomplicated if it contained that check. But you may find yourself in a more difficult situation in which it’s hard to prevent your application from making unnecessary calls to out-of-process dependencies without passing those dependencies to the domain model, thus overcomplicating that domain model. The only way to prevent such overcomplication is the use of domain events.
从实现的角度来看,领域事件是一个包含通知外部系统所需数据的类。在我们的具体示例中,它是用户的 ID 和电子邮件:
From an implementation standpoint, a domain event is a class that contains data needed to notify external systems. In our specific example, it is the user’s ID and email:
公共类EmailChangedEvent
{
公共 int UserId { 获取; }
公共字符串 NewEmail { 获取; }
}public class EmailChangedEvent
{
public int UserId { get; }
public string NewEmail { get; }
}
领域事件应始终以过去时命名,因为它们表示已经发生的事情。领域事件是值 — 它们是不可变且可互换的。
Domain events should always be named in the past tense because they represent things that already happened. Domain events are values—they are immutable and interchangeable.
User将拥有一个此类事件的集合,当电子邮件发生变化时,它将向其中添加一个新元素。这就是其ChangeEmail()方法在重构后的样子。
User will have a collection of such events to which it will add a new element when the email changes. This is how its ChangeEmail() method looks after the refactoring.
公共无效更改电子邮件(字符串新电子邮件,公司公司)
{
前提条件.需要(CanChangeEmail()==null);
如果(电子邮件==新电子邮件)
返回;
用户类型新类型 = 公司.IsEmailCorporate(新电子邮件)
? 用户类型.员工
:用户类型.客户;
如果(类型!=新类型)
{
int delta = newType == UserType.Employee ? 1 : -1;
公司.改变员工人数(delta);
}
电子邮件=新电子邮件;
类型=新类型;
EmailChangedEvents.添加( 1
新EmailChangedEvent(UserId,newEmail)); 1
}public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
}
Email = newEmail;
Type = newType;
EmailChangedEvents.Add( 1
new EmailChangedEvent(UserId, newEmail)); 1
}
然后控制器会将事件转换为总线上的消息。
The controller then will convert the events into messages on the bus.
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
对象[] 用户数据 = _database.GetUserById(用户Id);
用户用户 = UserFactory.创建(用户数据);
字符串错误 = 用户.CanChangeEmail();
如果(错误!=空)
返回错误;
对象[] companyData = _database.GetCompany();
公司公司 = CompanyFactory.Create(companyData);
用户.更改电子邮件(新电子邮件,公司);
_数据库.保存公司(公司);
_数据库.保存用户(用户);
foreach(var ev 在 user.EmailChangedEvents 中) 1
{ 1
_messageBus.SendEmailChangedMessage( 1
ev.UserId,ev.NewEmail); 1
} 1
返回“OK”;
}public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
foreach (var ev in user.EmailChangedEvents) 1
{ 1
_messageBus.SendEmailChangedMessage( 1
ev.UserId, ev.NewEmail); 1
} 1
return "OK";
}
请注意,Company和User实例仍然无条件地持久保存在数据库中:持久性逻辑不依赖于域事件。这是由于数据库中的更改与总线中的消息之间的差异造成的。
Notice that the Company and User instances are still persisted in the database unconditionally: the persistence logic doesn’t depend on domain events. This is due to the difference between changes in the database and messages in the bus.
假设除 CRM 之外没有其他应用程序可以访问数据库,与该数据库的通信就不是 CRM 可观察行为的一部分 — 它们是实现细节。只要数据库的最终状态正确,应用程序对该数据库进行多少次调用都无关紧要。另一方面,与消息总线的通信是应用程序可观察行为的一部分。为了保持与外部系统的联系,CRM 应该只在电子邮件发生变化时将消息放在总线上。
Assuming that no application has access to the database other than the CRM, communications with that database are not part of the CRM’s observable behavior—they are implementation details. As long as the final state of the database is correct, it doesn’t matter how many calls your application makes to that database. On the other hand, communications with the message bus are part of the application’s observable behavior. In order to maintain the contract with external systems, the CRM should put messages on the bus only when the email changes.
无条件地将数据持久化到数据库中会对性能产生影响,但影响相对较小。经过所有验证后,新电子邮件与旧电子邮件相同的可能性非常小。使用 ORM 也会有所帮助。如果对象状态没有变化,大多数 ORM 都不会往返数据库。
There are performance implications to persisting data in the database unconditionally, but they are relatively insignificant. The chances that after all the validations the new email is the same as the old one are quite small. The use of an ORM can also help. Most ORMs won’t make a round trip to the database if there are no changes to the object state.
您可以使用域事件来概括解决方案:提取一个DomainEvent基类并为所有域类引入一个基类,该基类将包含此类事件的集合:List<DomainEvent> events。您还可以编写单独的事件调度程序,而不是在控制器中手动调度域事件。最后,在较大的项目中,您可能需要一种在调度域事件之前合并域事件的机制。不过,该主题超出了本书的范围。您可以在http://mng.bz/YeVe上阅读我的文章“调度前合并域事件” 。
You can generalize the solution with domain events: extract a DomainEvent base class and introduce a base class for all domain classes, which would contain a collection of such events: List<DomainEvent> events. You can also write a separate event dispatcher instead of dispatching domain events manually in controllers. Finally, in larger projects, you might need a mechanism for merging domain events before dispatching them. That topic is outside the scope of this book, though. You can read about it in my article “Merging domain events before dispatching” at http://mng.bz/YeVe.
域事件将决策责任从控制器中移除,并将该责任放入域模型中,从而简化与外部系统的单元测试通信。您无需验证控制器本身并使用模拟来替代进程外依赖项,而是可以直接在单元测试中测试域事件的创建,如下所示。
Domain events remove the decision-making responsibility from the controller and put that responsibility into the domain model, thus simplifying unit testing communications with external systems. Instead of verifying the controller itself and using mocks to substitute out-of-process dependencies, you can test the domain event creation directly in unit tests, as shown next.
[事实]
公共无效 Changing_email_from_corporate_to_non_corporate()
{
var company = new Company("mycorp.com", 1);
var sut = new User(1, "user@mycorp.com", UserType.Employee, false);
sut.ChangeEmail("new@gmail.com", 公司);
公司.员工人数.应该是(0);
sut.Email.Should().Be("new@gmail.com");
sut.类型.应该()。是(用户类型.客户);
sut.EmailChangedEvents.Should().Equal( 1
new EmailChangedEvent(1, "new@gmail.com")); 1
}[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
var company = new Company("mycorp.com", 1);
var sut = new User(1, "user@mycorp.com", UserType.Employee, false);
sut.ChangeEmail("new@gmail.com", company);
company.NumberOfEmployees.Should().Be(0);
sut.Email.Should().Be("new@gmail.com");
sut.Type.Should().Be(UserType.Customer);
sut.EmailChangedEvents.Should().Equal( 1
new EmailChangedEvent(1, "new@gmail.com")); 1
}
当然,您仍然需要测试控制器以确保它正确地进行编排,但这样做需要的测试数量要少得多。这是下一章的主题。
Of course, you’ll still need to test the controller to make sure it does the orchestration correctly, but doing so requires a much smaller set of tests. That’s the topic of the next chapter.
请注意本章中一直存在的一个主题:将副作用的应用抽象到外部系统。通过将这些副作用保留在内存中直到业务操作的最后,您可以实现这种抽象,这样就可以使用普通的单元测试来测试它们,而无需涉及进程外依赖关系。域事件是总线中即将出现的消息的抽象。域类中的更改是数据库中即将进行的修改的抽象。
Notice a theme that has been present throughout this chapter: abstracting away the application of side effects to external systems. You achieve such abstraction by keeping those side effects in memory until the very end of the business operation, so that they can be tested with plain unit tests without involving out-of-process dependencies. Domain events are abstractions on top of upcoming messages in the bus. Changes in domain classes are abstractions on top of upcoming modifications in the database.
测试抽象概念比测试抽象出来的事物本身更加容易。
It’s easier to test abstractions than the things they abstract.
尽管我们能够借助领域事件和 CanExecute/Execute 模式成功地将所有决策都包含在领域模型中,但您无法总是做到这一点。在某些情况下,业务逻辑碎片化是不可避免的。
Although we were able to successfully contain all the decision-making in the domain model with the help of domain events and the CanExecute/Execute pattern, you won’t be able to always do that. There are situations where business logic fragmentation is inevitable.
例如,如果不在域模型中引入进程外依赖项,就无法在控制器之外验证电子邮件的唯一性。另一个示例是进程外依赖项的故障,这应该会改变业务操作的进程。关于走哪条路的决定不能留在域层,因为不是域层调用这些进程外依赖项。您必须将此逻辑放入控制器中,然后用集成测试覆盖它。尽管如此,即使存在潜在的碎片化,将业务逻辑与业务流程分离仍然有很大的价值,因为这种分离大大简化了单元测试过程。
For example, there’s no way to verify email uniqueness outside the controller without introducing out-of-process dependencies in the domain model. Another example is failures in out-of-process dependencies that should alter the course of the business operation. The decision about which way to go can’t reside in the domain layer because it’s not the domain layer that calls those out-of-process dependencies. You will have to put this logic into controllers and then cover it with integration tests. Still, even with the potential fragmentation, there’s a lot of value in separating business logic from orchestration because this separation drastically simplifies the unit testing process.
正如您无法避免在控制器中有一些业务逻辑一样,您很少能够从域类中删除所有协作者。这没问题。一个、两个甚至三个协作者不会将域类变成过于复杂的代码,只要这些协作者不引用进程外依赖项即可。
Just as you can’t avoid having some business logic in controllers, you will rarely be able to remove all collaborators from domain classes. And that’s fine. One, two, or even three collaborators won’t turn a domain class into overcomplicated code, as long as these collaborators don’t refer to out-of-process dependencies.
不过,不要使用模拟来验证与此类协作者的交互。这些交互与域模型的可观察行为无关。只有从控制器到域类的第一次调用才与该控制器的目标有直接联系。域类在同一操作中对其邻居域类进行的所有后续调用都是实现细节。
Don’t use mocks to verify interactions with such collaborators, though. These interactions have nothing to do with the domain model’s observable behavior. Only the very first call, which goes from a controller to a domain class, has an immediate connection to that controller’s goal. All the subsequent calls the domain class makes to its neighbor domain classes within the same operation are implementation details.
图 7.13说明了这个想法。它显示了 CRM 中组件之间的通信及其与可观察行为的关系。您可能还记得第 5 章的内容,方法是否是类的可观察行为的一部分取决于客户端是谁以及该客户端的目标是什么。要成为可观察行为的一部分,该方法必须满足以下两个标准之一:
Figure 7.13 illustrates this idea. It shows the communications between components in the CRM and their relationship to observable behavior. As you may remember from chapter 5, whether a method is part of the class’s observable behavior depends on whom the client is and what the goals of that client are. To be part of the observable behavior, the method must meet one of the following two criteria:
控制器的ChangeEmail()方法是其可观察行为的一部分,它对消息总线的调用也是如此。第一个方法是外部客户端的入口点,因此满足第一个标准。对总线的调用将消息发送到外部应用程序,因此满足第二个标准。您应该验证这两个方法调用(这是下一章的主题)。但是,控制器对的后续调用User与外部客户端的目标没有直接联系。只要系统的最终状态正确并且对消息总线的调用到位,该客户端就不会关心控制器如何决定实现电子邮件的更改。因此,User在测试控制器的行为时,您不应该验证控制器对的调用。
The controller’s ChangeEmail() method is part of its observable behavior, and so is the call it makes to the message bus. The first method is the entry point for the external client, thereby meeting the first criterion. The call to the bus sends messages to external applications, thereby meeting the second criterion. You should verify both of these method calls (which is the topic of the next chapter). However, the subsequent call from the controller to User doesn’t have an immediate connection to the goals of the external client. That client doesn’t care how the controller decides to implement the change of email as long as the final state of the system is correct and the call to the message bus is in place. Therefore, you shouldn’t verify calls the controller makes to User when testing that controller’s behavior.
当您沿调用堆栈向下移动一级时,您会遇到类似的情况。现在控制器是客户端,并且ChangeEmail中的方法User与该客户端更改用户电子邮件的目标有直接联系,因此应该进行测试。但是从User到 的后续调用Company从控制器的角度来看是实现细节。因此,涵盖ChangeEmail中方法的测试User不应验证User上调用了哪些方法。当您再向下移动一级并从的角度Company测试 中的两个方法时,同样的推理也适用。CompanyUser
When you step one level down the call stack, you get a similar situation. Now it’s the controller who is the client, and the ChangeEmail method in User has an immediate connection to that client’s goal of changing the user email and thus should be tested. But the subsequent calls from User to Company are implementation details from the controller’s point of view. Therefore, the test that covers the ChangeEmail method in User shouldn’t verify what methods User calls on Company. The same line of reasoning applies when you step one more level down and test the two methods in Company from User’s point of view.
将可观察的行为和实现细节视为洋葱层。从外层的角度测试每一层,而忽略该层如何与底层通信。当您逐层剥开这些层时,您会切换视角:以前的实现细节现在变成了可观察的行为,然后您可以使用另一组测试来覆盖它。
Think of the observable behavior and implementation details as onion layers. Test each layer from the outer layer’s point of view, and disregard how that layer talks to the underlying layers. As you peel these layers one by one, you switch perspective: what previously was an implementation detail now becomes an observable behavior, which you then cover with another set of tests.
您是否遇到过所有单元测试都通过但应用程序仍然无法运行的情况?独立验证软件组件很重要,但检查这些组件与外部系统集成时的工作方式也同样重要。这就是集成测试发挥作用的地方。
Have you ever been in a situation where all the unit tests pass but the application still doesn’t work? Validating software components in isolation from each other is important, but it’s equally important to check how those components work in integration with external systems. This is where integration testing comes into play.
在第 8 章中,我们将从总体上介绍集成测试并重新讨论测试金字塔概念。您将了解集成测试固有的权衡以及如何驾驭它们。第 9 章和第 10 章将讨论更具体的主题。第 9 章将教您如何充分利用模拟。第 10 章深入探讨了在测试中使用关系数据库。
In chapter 8, we’ll look at integration testing in general and revisit the Test Pyramid concept. You’ll learn the trade-offs inherent to integration testing and how to navigate them. Chapters 9 and 10 will then discuss more specific topics. Chapter 9 will teach you how to get the most out of your mocks. Chapter 10 is a deep dive into working with relational databases in tests.
如果完全依赖单元测试,您永远无法确定整个系统是否正常工作。单元测试非常适合验证业务逻辑,但仅仅在真空中检查该逻辑是不够的。您必须验证系统的不同部分如何相互集成以及与外部系统(数据库、消息总线等)的集成。
You can never be sure your system works as a whole if you rely on unit tests exclusively. Unit tests are great at verifying business logic, but it’s not enough to check that logic in a vacuum. You have to validate how different parts of it integrate with each other and external systems: the database, the message bus, and so on.
在本章中,您将了解集成测试的作用:何时应应用它们,何时最好依赖普通的旧单元测试或其他技术(例如快速失败原则)。您将看到在集成测试中应按原样使用哪些进程外依赖项,以及应使用模拟替换哪些进程外依赖项。您还将看到有助于改善代码库健康状况的集成测试最佳实践:明确领域模型边界、减少应用程序中的层数以及消除循环依赖。最后,您将了解为什么应偶尔使用具有单一实现的接口,以及如何以及何时测试日志记录功能。
In this chapter, you’ll learn the role of integration tests: when you should apply them and when it’s better to rely on plain old unit tests or even other techniques such as the Fail Fast principle. You will see which out-of-process dependencies to use as-is in integration tests and which to replace with mocks. You will also see integration testing best practices that will help improve the health of your code base in general: making domain model boundaries explicit, reducing the number of layers in the application, and eliminating circular dependencies. Finally, you’ll learn why interfaces with a single implementation should be used sporadically, and how and when to test logging functionality.
集成测试在您的测试套件中扮演着重要的角色。平衡单元测试和集成测试的数量也至关重要。您很快就会看到这个角色是什么以及如何保持平衡,但首先,让我重新介绍一下集成测试和单元测试的区别。
Integration tests play an important role in your test suite. It’s also crucial to balance the number of unit and integration tests. You will see shortly what that role is and how to maintain the balance, but first, let me give you a refresher on what differentiates an integration test from a unit test.
您可能还记得第 2 章中的内容,单元测试是满足以下三个要求的测试:
As you may remember from chapter 2, a unit test is a test that meets the following three requirements:
不满足上述三项要求之一的测试属于集成测试类别。集成测试是任何非单元测试的测试。
A test that doesn’t meet at least one of these three requirements falls into the category of integration tests. An integration test then is any test that is not a unit test.
实际上,集成测试几乎总是验证系统与进程外依赖项集成时的工作方式。换句话说,这些测试涵盖了控制器象限中的代码(有关代码象限的更多详细信息,请参阅第 7 章)。图 8.1中的图表显示了单元测试和集成测试的典型职责。单元测试涵盖领域模型,而集成测试则检查将该领域模型与进程外依赖项粘合在一起的代码。
In practice, integration tests almost always verify how your system works in integration with out-of-process dependencies. In other words, these tests cover the code from the controllers quadrant (see chapter 7 for more details about code quadrants). The diagram in figure 8.1 shows the typical responsibilities of unit and integration tests. Unit tests cover the domain model, while integration tests check the code that glues that domain model with out-of-process dependencies.
请注意,涵盖控制器象限的测试有时也可以是单元测试。如果所有进程外依赖项都用模拟替换,则测试之间将不会共享依赖项,这将使这些测试保持快速并保持彼此隔离。不过,大多数应用程序确实有一个进程外依赖项,无法用模拟替换。它通常是一个数据库——其他应用程序不可见的依赖项。
Note that tests covering the controllers quadrant can sometimes be unit tests too. If all out-of-process dependencies are replaced with mocks, there will be no dependencies shared between tests, which will allow those tests to remain fast and maintain their isolation from each other. Most applications do have an out-of-process dependency that can’t be replaced with a mock, though. It’s usually a database—a dependency that is not visible to other applications.
您可能还记得第 7 章的内容,图 8.1中的其他两个象限(简单代码和过于复杂的代码)根本不应该进行测试。简单代码不值得付出努力,而过于复杂的代码则应重构为算法和控制器。因此,您的所有测试都必须专注于域模型和控制器象限。
As you may also remember from chapter 7, the other two quadrants from figure 8.1 (trivial code and overcomplicated code) shouldn’t be tested at all. Trivial code isn’t worth the effort, while overcomplicated code should be refactored into algorithms and controllers. Thus, all your tests must focus on the domain model and the controllers quadrants exclusively.
保持单元测试和集成测试之间的平衡很重要。直接处理进程外依赖关系会使集成测试变慢。此类测试的维护成本也更高。可维护性成本的增加是由于
It’s important to maintain a balance between unit and integration tests. Working directly with out-of-process dependencies makes integration tests slow. Such tests are also more expensive to maintain. The increase in maintainability costs is due to
另一方面,集成测试需要处理大量代码(包括您的代码和应用程序使用的库的代码),这使得它们在防止回归方面比单元测试更好。它们也与生产代码更加分离,因此对重构的抵抗力更强。
On the other hand, integration tests go through a larger amount of code (both your code and the code of the libraries used by the application), which makes them better than unit tests at protecting against regressions. They are also more detached from the production code and therefore have better resistance to refactoring.
单元测试和集成测试之间的比例可能因项目的具体情况而有所不同,但一般的经验法则如下:使用单元测试检查尽可能多的业务场景的边缘情况;使用集成测试来覆盖一条快乐路径,以及单元测试无法覆盖的任何边缘情况。
The ratio between unit and integration tests can differ depending on the project’s specifics, but the general rule of thumb is the following: check as many of the business scenario’s edge cases as possible with unit tests; use integration tests to cover one happy path, as well as any edge cases that can’t be covered by unit tests.
顺利路径是指业务场景成功执行。边缘情况是指业务场景执行导致错误。
A happy path is a successful execution of a business scenario. An edge case is when the business scenario execution results in an error.
将大部分工作量转移到单元测试有助于降低维护成本。同时,每个业务场景进行一到两个总体集成测试可确保整个系统的正确性。该准则形成了单元测试和集成测试之间的金字塔式比例,如图8.2所示(如第 2 章所述,端到端测试是集成测试的一个子集)。
Shifting the majority of the workload to unit tests helps keep maintenance costs low. At the same time, having one or two overarching integration tests per business scenario ensures the correctness of your system as a whole. This guideline forms the pyramid-like ratio between unit and integration tests, as shown in figure 8.2 (as discussed in chapter 2, end-to-end tests are a subset of integration tests).
测试金字塔可以根据项目的复杂程度呈现不同的形状。简单的应用程序在领域模型和算法象限中几乎没有代码(如果有的话)。因此,测试形成一个矩形而不是金字塔,其中单元测试和集成测试的数量相等(图 8.3)。在最简单的情况下,您可能根本没有单元测试。
The Test Pyramid can take different shapes depending on the project’s complexity. Simple applications have little (if any) code in the domain model and algorithms quadrant. As a result, tests form a rectangle instead of a pyramid, with an equal number of unit and integration tests (figure 8.3). In the most trivial cases, you might have no unit tests whatsoever.
请注意,集成测试即使在简单的应用程序中也具有其价值。无论您的代码多么简单,验证它与其他子系统的集成效果仍然很重要。
Note that integration tests retain their value even in simple applications. Regardless of how simple your code is, it’s still important to verify how it works in integration with other subsystems.
本节详细说明了使用集成测试覆盖每个业务场景的一条顺利路径以及单元测试无法覆盖的任何边缘情况的指南。
This section elaborates on the guideline of using integration tests to cover one happy path per business scenario and any edge cases that can’t be covered by unit tests.
对于集成测试,请选择最长的幸福路径,以验证与所有进程外依赖项的交互。如果没有一条路径可以完成所有这些交互,请编写额外的集成测试 - 尽可能多的测试,以捕获与每个外部系统的通信。
For an integration test, select the longest happy path in order to verify interactions with all out-of-process dependencies. If there’s no one path that goes through all such interactions, write additional integration tests—as many as needed to capture communications with every external system.
与单元测试无法覆盖的边缘情况一样,本指南的这一部分也有例外。如果边缘情况的错误执行会立即导致整个应用程序失败,则无需测试该边缘情况。例如,您在第 7 章中看到了如何User从示例 CRM 系统中实现CanChangeEmail方法并将其成功执行作为以下操作的先决条件ChangeEmail():
As with the edge cases that can’t be covered by unit tests, there are exceptions to this part of the guideline, too. There’s no need to test an edge case if an incorrect execution of that edge case immediately fails the entire application. For example, you saw in chapter 7 how User from the sample CRM system implemented a CanChangeEmail method and made its successful execution a precondition for ChangeEmail():
公共无效更改电子邮件(字符串新电子邮件,公司公司)
{
前提条件.需要(CanChangeEmail()==null);
/* 方法的其余部分 */
}public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
/* the rest of the method */
}
如果该方法返回错误,控制器将调用CanChangeEmail()并中断操作:
The controller invokes CanChangeEmail() and interrupts the operation if that method returns an error:
// 用户控制器
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
对象[] 用户数据 = _database.GetUserById(用户Id);
用户用户 = UserFactory.创建(用户数据);
字符串错误 = 用户.CanChangeEmail();
如果 (error != null) 1
返回错误; 1
/* 方法的其余部分 */
}// UserController
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null) 1
return error; 1
/* the rest of the method */
}
此示例显示了理论上可以使用集成测试覆盖的极端情况。不过,这样的测试并没有提供足够重要的价值。如果控制器在未CanChangeEmail()事先协商的情况下尝试更改电子邮件,应用程序就会崩溃。此错误在第一次执行时就会显现出来,因此很容易发现和修复。它也不会导致数据损坏。
This example shows the edge case you could theoretically cover with an integration test. Such a test doesn’t provide a significant enough value, though. If the controller tries to change the email without consulting with CanChangeEmail() first, the application crashes. This bug reveals itself with the first execution and thus is easy to notice and fix. It also doesn’t lead to data corruption.
写一个糟糕的测试比根本不写测试要好。没有提供重要价值的测试就是糟糕的测试。
It’s better to not write a test at all than to write a bad test. A test that doesn’t provide significant value is a bad test.
与从控制器到 的调用不同,应该CanChangeEmail()测试中的前提条件的存在。但最好使用单元测试来完成;无需进行集成测试。User
Unlike the call from the controller to CanChangeEmail(), the presence of the precondition in User should be tested. But that is better done with a unit test; there’s no need for an integration test.
让错误快速显现出来被称为“快速失败”原则,它是集成测试的可行替代方案。
Making bugs manifest themselves quickly is called the Fail Fast principle, and it’s a viable alternative to integration testing.
Fail Fast 原则代表一旦发生任何意外错误,立即停止当前操作。此原则通过以下方式使您的应用程序更加稳定:
The Fail Fast principle stands for stopping the current operation as soon as any unexpected error occurs. This principle makes your application more stable by
停止当前操作通常是通过引发异常来完成的,因为异常具有非常适合快速失败原则的语义:它们会中断程序流程并弹出到执行堆栈的最高级别,您可以在其中记录它们并关闭或重新启动操作。
Stopping the current operation is normally done by throwing exceptions, because exceptions have semantics that are perfectly suited for the Fail Fast principle: they interrupt the program flow and pop up to the highest level of the execution stack, where you can log them and shut down or restart the operation.
先决条件是快速失败原则的一个例子。先决条件不成立表示对应用程序状态的假设不正确,这始终是一个错误。另一个例子是从配置文件中读取数据。您可以安排读取逻辑,以便在配置文件中的数据不完整或不正确时抛出异常。您还可以将此逻辑放在应用程序启动附近,这样如果配置有问题,应用程序就不会启动。
Preconditions are one example of the Fail Fast principle in action. A failing precondition signifies an incorrect assumption made about the application state, which is always a bug. Another example is reading data from a configuration file. You can arrange the reading logic such that it will throw an exception if the data in the configuration file is incomplete or incorrect. You can also put this logic close to the application startup, so that the application doesn’t launch if there’s a problem with its configuration.
正如我之前提到的,集成测试会验证您的系统如何与进程外依赖项集成。有两种方法可以实现此类验证:使用真实的进程外依赖项,或用模拟替换该依赖项。本节将介绍何时应用这两种方法。
As I mentioned earlier, integration tests verify how your system integrates with out-of-process dependencies. There are two ways to implement such verification: use the real out-of-process dependency, or replace that dependency with a mock. This section shows when to apply each of the two approaches.
所有进程外依赖项都分为两类:
All out-of-process dependencies fall into two categories:
我在第 5 章中提到,与托管依赖项的通信是实现细节。相反,与非托管依赖项的通信是系统可观察行为的一部分(图 8.4)。这种区别导致了集成测试中对进程外依赖项的不同处理。
I mentioned in chapter 5 that communications with managed dependencies are implementation details. Conversely, communications with unmanaged dependencies are part of your system’s observable behavior (figure 8.4). This distinction leads to the difference in treatment of out-of-process dependencies in integration tests.
使用托管依赖项的真实实例;用模拟替换非托管依赖项。
Use real instances of managed dependencies; replace unmanaged dependencies with mocks.
如第 5 章所述,保留非托管依赖项的通信模式的要求源于保持与这些依赖项的向后兼容性的必要性。模拟非常适合这项任务。使用模拟,您可以确保通信模式在任何可能的重构情况下的持久性。
As discussed in chapter 5, the requirement to preserve the communication pattern with unmanaged dependencies stems from the necessity to maintain backward compatibility with those dependencies. Mocks are perfect for this task. With mocks, you can ensure communication pattern permanence in light of any possible refactorings.
但是,在与托管依赖项的通信中无需保持向后兼容性,因为您的应用程序是唯一与它们通信的应用程序。外部客户端不关心您如何组织数据库;唯一重要的是系统的最终状态。在集成测试中使用托管依赖项的真实实例有助于您从外部客户端的角度验证最终状态。它还有助于数据库重构,例如重命名列或甚至从一个数据库迁移到另一个数据库。
However, there’s no need to maintain backward compatibility in communications with managed dependencies, because your application is the only one that talks to them. External clients don’t care how you organize your database; the only thing that matters is the final state of your system. Using real instances of managed dependencies in integration tests helps you verify that final state from the external client’s point of view. It also helps during database refactorings, such as renaming a column or even migrating from one database to another.
有时,您会遇到进程外依赖项,它同时具有托管依赖项和非托管依赖项的属性。其他应用程序可以访问的数据库就是一个很好的例子。
Sometimes you’ll encounter an out-of-process dependency that exhibits attributes of both managed and unmanaged dependencies. A good example is a database that other applications have access to.
故事通常是这样的。一个系统从它自己的专用数据库开始。过了一段时间,另一个系统开始需要来自同一数据库的数据。因此,团队决定共享有限数量表的访问权限,以便于与其他系统集成。结果,数据库成为既受管理又不受管理的依赖项。它仍然包含仅对您的应用程序可见的部分;但除了这些部分之外,它还有许多其他应用程序可访问的表。
The story usually goes like this. A system begins with its own dedicated database. After a while, another system begins to require data from the same database. And so the team decides to share access to a limited number of tables just for ease of integration with that other system. As a result, the database becomes a dependency that is both managed and unmanaged. It still contains parts that are visible to your application only; but, in addition to those parts, it also has a number of tables accessible by other applications.
使用数据库是实现系统间集成的糟糕方法,因为它会使这些系统相互耦合,并使进一步的开发变得复杂。只有在所有其他选项都用尽时才采用这种方法。进行集成的更好方法是通过 API(用于同步通信)或消息总线(用于异步通信)。
The use of a database is a poor way to implement integration between systems because it couples these systems to each other and complicates their further development. Only resort to this approach when all other options are exhausted. A better way to do the integration is via an API (for synchronous communications) or a message bus (for asynchronous communications).
但是,如果你已经有一个共享数据库,并且在可预见的未来无法对其进行任何操作,你该怎么办?在这种情况下,将对其他应用程序作为非托管依赖项。此类表实际上充当消息总线,其中的行充当消息的角色。使用模拟来确保与这些表的通信模式保持不变。同时,将数据库的其余部分视为托管依赖项并验证其最终状态,而不是与它的交互(图 8.5)。
But what do you do when you already have a shared database and can’t do anything about it in the foreseeable future? In this case, treat tables that are visible to other applications as an unmanaged dependency. Such tables in effect act as a message bus, with their rows playing the role of messages. Use mocks to make sure the communication pattern with these tables remains unchanged. At the same time, treat the rest of your database as a managed dependency and verify its final state, not the interactions with it (figure 8.5).
区分数据库的这两个部分非常重要,因为共享表在外部是可见的,因此您需要小心应用程序与它们的通信方式。除非绝对必要,否则不要更改系统与这些表的交互方式!您永远不知道其他应用程序将如何应对这种变化。
It’s important to differentiate these two parts of your database because, again, the shared tables are observable externally, and you need to be careful about how your application communicates with them. Don’t change the way your system interacts with those tables unless absolutely necessary! You never know how other applications will react to such a change.
有时,由于超出控制范围的原因,您无法在集成测试中使用托管依赖项的真实版本。例如,由于某些 IT 安全策略,或者由于设置和维护测试数据库实例的成本过高,您无法将旧数据库部署到测试自动化环境,更不用说开发人员机器了。
Sometimes, for reasons outside of your control, you just can’t use a real version of a managed dependency in integration tests. An example would be a legacy database that you can’t deploy to a test automation environment, not to mention a developer machine, because of some IT security policy, or because the cost of setting up and maintaining a test database instance is prohibitive.
在这种情况下你应该怎么做?尽管数据库是托管依赖项,你还是应该模拟数据库吗?不,因为模拟托管依赖项会损害集成测试对重构的抵抗力。此外,这样的测试不再提供针对回归的良好保护。如果数据库是项目中唯一的进程外依赖项,则与现有的单元测试集相比,最终的集成测试不会提供额外的保护(假设这些单元测试遵循第 7 章中的指导原则)。
What should you do in such a situation? Should you mock out the database anyway, despite it being a managed dependency? No, because mocking out a managed dependency compromises the integration tests’ resistance to refactoring. Furthermore, such tests no longer provide as good protection against regressions. And if the database is the only out-of-process dependency in your project, the resulting integration tests would deliver no additional protection compared to the existing set of unit tests (assuming these unit tests follow the guidelines from chapter 7).
除了单元测试之外,此类集成测试唯一能做的事情就是检查控制器调用了哪些存储库方法。换句话说,除了控制器中的这三行代码是正确的之外,您无法真正确信其他任何事情,同时仍然需要进行大量的管道工作。
The only thing such integration tests would do, in addition to unit tests, is check what repository methods the controller calls. In other words, you wouldn’t really gain confidence about anything other than those three lines of code in your controller being correct, while still having to do a lot of plumbing.
如果您无法按原样测试数据库,则根本不要编写集成测试,而应专注于域模型的单元测试。请记住始终对所有测试进行严格审查。价值不够高的测试不应该出现在您的测试套件中。
If you can’t test the database as-is, don’t write integration tests at all, and instead, focus exclusively on unit testing of the domain model. Remember to always put all your tests under close scrutiny. Tests that don’t provide a high enough value should have no place in your test suite.
让我们回到第 7 章中的示例 CRM 系统,看看如何用集成测试覆盖它。您可能还记得,该系统实现了一项功能:更改用户的电子邮件。它从数据库中检索用户和公司,将决策委托给域模型,然后将结果保存回数据库并在需要时将消息放在总线上(图 8.6)。
Let’s get back to the sample CRM system from chapter 7 and see how it can be covered with integration tests. As you may recall, this system implements one feature: changing the user’s email. It retrieves the user and the company from the database, delegates the decision-making to the domain model, and then saves the results back to the database and puts a message on the bus if needed (figure 8.6).
下面的列表显示了控制器当前的外观。
The following listing shows how the controller currently looks.
公共类用户控制器
{
私有只读数据库 _database = new Database();
私有只读MessageBus _messageBus = new MessageBus();
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
对象[] 用户数据 = _database.GetUserById(用户Id);
用户用户 = UserFactory.创建(用户数据);
字符串错误 = 用户.CanChangeEmail();
如果(错误!=空)
返回错误;
对象[] companyData = _database.GetCompany();
公司公司 = CompanyFactory.Create(companyData);
用户.更改电子邮件(新电子邮件,公司);
_数据库.保存公司(公司);
_数据库.保存用户(用户);
foreach(用户中的 EmailChangedEvent ev。EmailChangedEvents)
{
_messageBus.SendEmailChangedMessage(ev.UserId,ev.NewEmail);
}
返回“OK”;
}
}public class UserController
{
private readonly Database _database = new Database();
private readonly MessageBus _messageBus = new MessageBus();
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
foreach (EmailChangedEvent ev in user.EmailChangedEvents)
{
_messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail);
}
return "OK";
}
}
在下一节中,我将首先概述使用集成测试进行验证的场景。然后我将向您展示如何在测试中使用数据库和消息总线。
In the following section, I’ll first outline scenarios to verify using integration tests. Then I’ll show you how to work with the database and the message bus in tests.
正如我之前提到的,集成测试的一般准则是覆盖最长的快乐路径和单元测试无法执行的任何边缘情况。最长的快乐路径是遍历所有进程外依赖项的路径。
As I mentioned earlier, the general guideline for integration testing is to cover the longest happy path and any edge cases that can’t be exercised by unit tests. The longest happy path is the one that goes through all out-of-process dependencies.
在 CRM 项目中,最长的快乐路径是从公司电子邮件更改为非公司电子邮件。这样的更改会导致最大数量的副作用:
In the CRM project, the longest happy path is a change from a corporate to a non-corporate email. Such a change leads to the maximum number of side effects:
至于单元测试未测试的极端情况,只有一种这样的极端情况:无法更改电子邮件的情况。不过,无需测试这种情况,因为如果控制器中没有此检查,应用程序将很快失败。这给我们留下了一个集成测试:
As for the edge cases that aren’t tested by unit tests, there’s only one such edge case: the scenario where the email can’t be changed. There’s no need to test this scenario, though, because the application will fail fast if this check isn’t present in the controller. That leaves us with a single integration test:
公共无效 Changing_email_from_corporate_to_non_corporate()
public void Changing_email_from_corporate_to_non_corporate()
在编写集成测试之前,您需要对两个进程外依赖项进行分类,并决定直接测试哪些依赖项以及用模拟替换哪些依赖项。应用程序数据库是托管依赖项,因为没有其他系统可以访问它。因此,您应该使用它的真实实例。集成测试将
Before writing the integration test, you need to categorize the two out-of-process dependencies and decide which of them to test directly and which to replace with a mock. The application database is a managed dependency because no other system can access it. Therefore, you should use a real instance of it. The integration test will
另一方面,消息总线是一种非托管依赖项 — 其唯一目的是实现与其他系统的通信。集成测试将模拟消息总线,然后验证控制器和模拟之间的交互。
On the other hand, the message bus is an unmanaged dependency—its sole purpose is to enable communication with other systems. The integration test will mock out the message bus and verify the interactions between the controller and the mock afterward.
我们的示例项目中不会有端到端测试。在具有 API 的场景中,端到端测试将针对已部署的、功能齐全的 API 版本运行测试,这意味着不会模拟任何进程外依赖项(图 8.7)。另一方面,集成测试在同一进程内托管应用程序,并用模拟替代非托管依赖项(图 8.8)。
There will be no end-to-end tests in our sample project. An end-to-end test in a scenario with an API would be a test running against a deployed, fully functioning version of that API, which means no mocks for any of the out-of-process dependencies (figure 8.7). On the other hand, integration tests host the application within the same process and substitute unmanaged dependencies with mocks (figure 8.8).
正如我在第 2 章中提到的,是否使用端到端测试是一个判断问题。在大多数情况下,当您在集成测试范围中包含托管依赖项并仅模拟非托管依赖项时,集成测试会提供一定程度的保护程度与端到端测试非常接近,因此您可以跳过端到端测试。但是,您仍然可以创建一两个总体端到端测试,以便在部署后为项目提供健全性检查。让此类测试也经过最长的快乐路径,以确保您的应用程序与所有进程外依赖项正确通信。要模拟外部客户端的行为,请直接检查消息总线,但通过应用程序本身验证数据库的状态。
As I mentioned in chapter 2, whether to use end-to-end tests is a judgment call. For the most part, when you include managed dependencies in the integration testing scope and mock out only unmanaged dependencies, integration tests provide a level of protection that is close enough to that of end-to-end tests, so you can skip end-to-end testing. However, you could still create one or two overarching end-to-end tests that would provide a sanity check for the project after deployment. Make such tests go through the longest happy path, too, to ensure that your application communicates with all out-of-process dependencies properly. To emulate the external client’s behavior, check the message bus directly, but verify the database’s state through the application itself.
这是集成测试的第一个版本。
Here’s the first version of the integration test.
[事实]
公共无效 Changing_email_from_corporate_to_non_corporate()
{
// 安排
var db = new Database(ConnectionString); 1
User user = CreateUser( 2
"user@mycorp.com", UserType.Employee, db); 2
CreateCompany("mycorp.com", 1, db); 2
var messageBusMock = new Mock<IMessageBus>(); 3
var sut = new UserController(db,messageBusMock.Object);
// 行为
字符串结果 = sut.ChangeEmail(用户.UserId,“new@gmail.com”);
// 断言
Assert.Equal("OK",结果);
对象[] 用户数据 = db.GetUserById(用户.UserId); 4
用户 userFromDb = UserFactory.Create(用户数据); 4
Assert.Equal("new@gmail.com", userFromDb.Email); 4
Assert.Equal(UserType.Customer, userFromDb.Type); 4
对象[] companyData = db.GetCompany(); 5
公司 companyFromDb = CompanyFactory 5
.Create(companyData); 5
Assert.Equal(0, companyFromDb.NumberOfEmployees); 5
messageBusMock.Verify( 6
x => x.SendEmailChangedMessage( 6
user.UserId, "new@gmail.com"), 6
Times.Once); 6
}[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
// Arrange
var db = new Database(ConnectionString); 1
User user = CreateUser( 2
"user@mycorp.com", UserType.Employee, db); 2
CreateCompany("mycorp.com", 1, db); 2
var messageBusMock = new Mock<IMessageBus>(); 3
var sut = new UserController(db, messageBusMock.Object);
// Act
string result = sut.ChangeEmail(user.UserId, "new@gmail.com");
// Assert
Assert.Equal("OK", result);
object[] userData = db.GetUserById(user.UserId); 4
User userFromDb = UserFactory.Create(userData); 4
Assert.Equal("new@gmail.com", userFromDb.Email); 4
Assert.Equal(UserType.Customer, userFromDb.Type); 4
object[] companyData = db.GetCompany(); 5
Company companyFromDb = CompanyFactory 5
.Create(companyData); 5
Assert.Equal(0, companyFromDb.NumberOfEmployees); 5
messageBusMock.Verify( 6
x => x.SendEmailChangedMessage( 6
user.UserId, "new@gmail.com"), 6
Times.Once); 6
}
请注意,在安排部分,测试不会自行将用户和公司插入数据库,而是调用CreateUser和CreateCompany辅助方法。这些方法可以在多个集成测试中重复使用。
Notice that in the arrange section, the test doesn’t insert the user and the company into the database on its own but instead calls the CreateUser and CreateCompany helper methods. These methods can be reused across multiple integration tests.
独立于用作输入参数的数据检查数据库的状态非常重要。为此,集成测试在断言部分分别查询用户和公司数据,创建新的userFromDb和companyFromDb实例,然后才断言它们的状态。这种方法可确保测试同时执行对数据库的写入和读取,从而提供最大程度的回归保护。读取本身必须使用控制器内部使用的相同代码来实现:在此示例中,使用Database、UserFactory和CompanyFactory类。
It’s important to check the state of the database independently of the data used as input parameters. To do that, the integration test queries the user and company data separately in the assert section, creates new userFromDb and companyFromDb instances, and only then asserts their state. This approach ensures that the test exercises both writes to and reads from the database and thus provides the maximum protection against regressions. The reading itself must be implemented using the same code the controller uses internally: in this example, using the Database, UserFactory, and CompanyFactory classes.
虽然这个集成测试已经完成了任务,但仍可以从一些改进中受益。例如,您也可以在断言部分使用辅助方法,以减少此部分的大小。此外,messageBusMock它无法提供尽可能好的回归保护。我们将在随后的两章中讨论这些改进,其中我们将讨论模拟和数据库测试的最佳实践。
This integration test, while it gets the job done, can still benefit from some improvement. For instance, you could use helper methods in the assertion section, too, in order to reduce this section’s size. Also, messageBusMock doesn’t provide as good protection against regressions as it potentially could. We’ll talk about these improvements in the subsequent two chapters where we discuss mocking and database testing best practices.
单元测试领域最容易被误解的主题之一是接口的使用。开发人员经常将引入接口的原因归咎于无效的原因,结果往往过度使用它们。在本节中,我将详细阐述这些无效的原因,并说明在什么情况下使用接口是可取的,什么情况下不宜使用接口。
One of the most misunderstood subjects in the sphere of unit testing is the use of interfaces. Developers often ascribe invalid reasons to why they introduce interfaces and, as a result, tend to overuse them. In this section, I’ll expand on those invalid reasons and show in what circumstances the use of interfaces is and isn’t preferable.
许多开发人员引入了进程外依赖项的接口,例如数据库或消息总线,即使这些接口只有一个实现。这种做法如今已经变得如此普遍,几乎没有人质疑它。您经常会看到类似以下的类接口对:
Many developers introduce interfaces for out-of-process dependencies, such as the database or the message bus, even when these interfaces have only one implementation. This practice has become so widespread nowadays that hardly anyone questions it. You’ll often see class-interface pairs similar to the following:
公共接口 IMessageBus 公共类消息总线:IMessageBus 公共接口 IUserRepository 公共类 UserRepository:IUserRepository
public interface IMessageBus public class MessageBus : IMessageBus public interface IUserRepository public class UserRepository : IUserRepository
使用此类接口的常见原因是它们有助于
The common reasoning behind the use of such interfaces is that they help to
这两个原因都是误解。具有单一实现的接口不是抽象,并且不会比实现这些接口的具体类提供更多的松散耦合。真正的抽象是被发现的,而不是发明的。根据定义,发现发生在事后,即抽象已经存在但尚未在代码中明确定义时。因此,对于一个真正的抽象,接口必须至少有两个实现。
Both of these reasons are misconceptions. Interfaces with a single implementation are not abstractions and don’t provide loose coupling any more than concrete classes that implement those interfaces. Genuine abstractions are discovered, not invented. The discovery, by definition, takes place post factum, when the abstraction already exists but is not yet clearly defined in the code. Thus, for an interface to be a genuine abstraction, it must have at least two implementations.
第二个原因(无需更改现有代码即可添加新功能)是一个误解,因为它违反了更基本的原则:YAGNI。YAGNI代表“你不会需要它”,并主张不要在目前不需要的功能上投入时间。您不应该开发此功能,也不应该修改现有代码以应对将来此类功能的出现。主要有两个原因:
The second reason (the ability to add new functionality without changing the existing code) is a misconception because it violates a more foundational principle: YAGNI. YAGNI stands for “You aren’t gonna need it” and advocates against investing time in functionality that’s not needed right now. You shouldn’t develop this functionality, nor should you modify your existing code to account for the appearance of such functionality in the future. The two major reasons are as follows:
编写代码是解决问题的一种昂贵方法。解决方案所需的代码越少,代码越简单越好。
Writing code is an expensive way to solve problems. The less code the solution requires and the simpler that code is, the better.
有一些例外情况不适用 YAGNI,但这种情况很少见。对于这些情况,请参阅我的文章“OCP vs YAGNI”,网址为https://enterprisecraftsmanship.com/posts/ocp-vs-yagni。
There are exceptional cases where YAGNI doesn’t apply, but these are few and far between. For those cases, see my article “OCP vs YAGNI,” at https://enterprisecraftsmanship.com/posts/ocp-vs-yagni.
那么,假设每个接口只有一个实现,为什么要使用接口来实现进程外依赖关系呢?真正的原因更加实际和实际。这是为了实现模拟 — 就这么简单。没有接口,您就无法创建测试替身,因此无法验证被测系统与进程外依赖关系之间的交互。
So, why use interfaces for out-of-process dependencies at all, assuming that each of those interfaces has only one implementation? The real reason is much more practical and down-to-earth. It’s to enable mocking—as simple as that. Without an interface, you can’t create a test double and thus can’t verify interactions between the system under test and the out-of-process dependency.
因此,除非需要模拟这些依赖项,否则不要引入进程外依赖项的接口。您只模拟非托管依赖项,因此指南可以归结为:仅对非托管依赖项使用接口。仍然将托管依赖项明确注入控制器,但为此使用具体类。
Therefore, don’t introduce interfaces for out-of-process dependencies unless you need to mock out those dependencies. You only mock out unmanaged dependencies, so the guideline can be boiled down to this: use interfaces for unmanaged dependencies only. Still inject managed dependencies into the controller explicitly, but use concrete classes for that.
请注意,真正的抽象(具有多个实现的抽象)可以用接口表示,无论您是否模拟它们。但是,出于模拟以外的原因引入具有单个实现的接口违反了 YAGNI。
Note that genuine abstractions (abstractions that have more than one implementation) can be represented with interfaces regardless of whether you mock them out. Introducing an interface with a single implementation for reasons other than mocking is a violation of YAGNI, however.
你可能已经注意到,在清单 8.2中,UserController现在通过构造函数显式接受消息总线和数据库,但只有消息总线具有相应的接口。数据库是托管依赖项,因此不需要这样的接口。这是控制器:
And you might have noticed in listing 8.2 that UserController now accepts both the message bus and the database explicitly via the constructor, but only the message bus has a corresponding interface. The database is a managed dependency and thus doesn’t require such an interface. Here’s the controller:
公共类用户控制器
{
私有只读数据库_database; 1
私有只读IMessageBus_messageBus; 2
公共用户控制器(数据库数据库,IMessageBus消息总线)
{
_database = 数据库;
_消息总线 = 消息总线;
}
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
/*该方法使用_database和_messageBus*/
}
}public class UserController
{
private readonly Database _database; 1
private readonly IMessageBus _messageBus; 2
public UserController(Database database, IMessageBus messageBus)
{
_database = database;
_messageBus = messageBus;
}
public string ChangeEmail(int userId, string newEmail)
{
/* the method uses _database and _messageBus */
}
}
您可以通过将依赖项中的方法设为虚拟方法并使用类本身作为模拟的基础来模拟依赖项,而无需借助接口。不过,这种方法不如使用接口的方法。我在第 11 章中详细解释了接口与基类这一主题。
You can mock out a dependency without resorting to an interface by making methods in that dependency virtual and using the class itself as a base for the mock. This approach is inferior to the one with interfaces, though. I explain more on this topic of interfaces versus base classes in chapter 11.
有时,您会看到代码库中的接口不仅支持进程外依赖项,还支持进程内依赖项。例如:
You sometimes see code bases where interfaces back not only out-of-process dependencies but in-process dependencies as well. For example:
公共接口IUser
{
int UserId { 获取; 设置; }
字符串电子邮件 { 获取; }
字符串 CanChangeEmail();
void ChangeEmail(string newEmail,公司公司);
}
公共类用户:IUser
{
/* ... */
}public interface IUser
{
int UserId { get; set; }
string Email { get; }
string CanChangeEmail();
void ChangeEmail(string newEmail, Company company);
}
public class User : IUser
{
/* ... */
}
假设IUser只有一个实现(并且此类特定接口始终只有一个实现),这是一个巨大的危险信号。就像进程外依赖项一样,为域类引入具有单一实现的接口的唯一原因是启用模拟。但与进程外依赖项不同,您永远不应检查域类之间的交互,因为这样做会导致脆弱的测试:测试与实现细节耦合,因此在抵抗重构的指标上失败(有关模拟和测试脆弱性的更多详细信息,请参阅第 5 章)。
Assuming that IUser has only one implementation (and such specific interfaces always have only one implementation), this is a huge red flag. Just like with out-of-process dependencies, the only reason to introduce an interface with a single implementation for a domain class is to enable mocking. But unlike out-of-process dependencies, you should never check interactions between domain classes, because doing so results in brittle tests: tests that couple to implementation details and thus fail on the metric of resisting to refactoring (see chapter 5 for more details about mocks and test fragility).
有一些通用准则可以帮助您充分利用集成测试:
There are some general guidelines that can help you get the most out of your integration tests:
通常,有利于测试的最佳实践也往往会改善整个代码库的健康状况。
As usual, best practices that are beneficial for tests also tend to improve the health of your code base in general.
尽量在代码库中始终为域模型留出明确、众所周知的位置。域模型是有关项目要解决的问题的域知识的集合。为域模型分配明确的边界有助于您更好地可视化和推理代码的该部分。
Try to always have an explicit, well-known place for the domain model in your code base. The domain model is the collection of domain knowledge about the problem your project is meant to solve. Assigning the domain model an explicit boundary helps you better visualize and reason about that part of your code.
这种做法也有助于测试。正如我在本章前面提到的,单元测试针对领域模型和算法,而集成测试针对控制器。领域类和控制器之间的明确界限使得区分单元测试和集成测试变得更容易。
This practice also helps with testing. As I mentioned earlier in this chapter, unit tests target the domain model and algorithms, while integration tests target controllers. The explicit boundary between domain classes and controllers makes it easier to tell the difference between unit and integration tests.
边界本身可以采用单独的程序集或命名空间的形式。具体细节并不重要,只要所有域逻辑都放在一个单独的框架下,而不是分散在代码库中即可。
The boundary itself can take the form of a separate assembly or a namespace. The particulars aren’t that important as long as all of the domain logic is put under a single, distinct umbrella and not scattered across the code base.
大多数程序员自然倾向于通过引入额外的间接层来抽象和概括代码。在典型的企业级应用程序中,您可以轻松观察到多个这样的层(图 8.9)。
Most programmers naturally gravitate toward abstracting and generalizing the code by introducing additional layers of indirection. In a typical enterprise-level application, you can easily observe several such layers (figure 8.9).
在极端情况下,应用程序会获得太多抽象层,以至于很难浏览代码库并理解哪怕是最简单的操作背后的逻辑。在某些时候,您只想找到手头问题的具体解决方案,而不是凭空对该解决方案进行概括。
In extreme cases, an application gets so many abstraction layers that it becomes too hard to navigate the code base and understand the logic behind even the simplest operations. At some point, you just want to get to the specific solution of the problem at hand, not some generalization of that solution in a vacuum.
计算机科学中的所有问题都可以通过另一层间接来解决,但间接层数过多的问题除外。
戴维·J·惠勒
All problems in computer science can be solved by another layer of indirection, except for the problem of too many layers of indirection.
David J. Wheeler
间接层会对您推理代码的能力产生负面影响。当每个功能在每个层中都有表示时,您必须花费大量精力将所有部分组装成一个连贯的画面。这会产生额外的心理负担,阻碍整个开发过程。
Layers of indirection negatively affect your ability to reason about the code. When every feature has a representation in each of those layers, you have to expend significant effort assembling all the pieces into a cohesive picture. This creates an additional mental burden that handicaps the entire development process.
过多的抽象也无助于单元测试或集成测试。具有许多间接层的代码库往往没有控制器和领域模型之间的明确界限(您可能还记得第 7 章中的内容,这是有效测试的先决条件)。还有一种更强烈的倾向,即分别验证每一层。这种倾向导致了大量低价值的集成测试,每个测试都只执行特定层的代码并模拟下面的层。最终结果总是一样的:对回归的保护不足,而且对重构的抵抗力低。
An excessive number of abstractions doesn’t help unit or integration testing, either. Code bases with many layers of indirections tend not to have a clear boundary between controllers and the domain model (which, as you might remember from chapter 7, is a precondition for effective tests). There’s also a much stronger tendency to verify each layer separately. This tendency results in a lot of low-value integration tests, each of which exercises only the code from a specific layer and mocks out layers underneath. The end result is always the same: insufficient protection against regressions combined with low resistance to refactoring.
尽量减少间接层。在大多数后端系统中,只需三层即可:域模型、应用服务层(控制器)和基础设施层。基础设施层通常由不属于域模型的算法以及允许访问进程外依赖项的代码组成(图 8.10)。
Try to have as few layers of indirection as possible. In most backend systems, you can get away with just three: the domain model, application services layer (controllers), and infrastructure layer. The infrastructure layer typically consists of algorithms that don’t belong to the domain model, as well as code that enables access to out-of-process dependencies (figure 8.10).
另一种可以显著提高代码库的可维护性并使测试更容易的做法是消除循环依赖。
Another practice that can drastically improve the maintainability of your code base and make testing easier is eliminating circular dependencies.
循环依赖(也称为周期性依赖)是两个或多个类直接或间接地相互依赖才能正常运行。
A circular dependency (also known as cyclic dependency) is two or more classes that directly or indirectly depend on each other to function properly.
循环依赖的一个典型例子是回调:
A typical example of a circular dependency is a callback:
公共类CheckOutService
{
公共无效CheckOut(int orderId)
{
var 服务 = 新的 ReportGenerationService();
服务.生成报告(订单编号,此);
/* 其他代码 */
}
}
公共类ReportGenerationService
{
公共无效生成报告(
int 订单编号,
检出服务 检出服务)
{
/* 生成完成后调用 checkOutService */
}
}public class CheckOutService
{
public void CheckOut(int orderId)
{
var service = new ReportGenerationService();
service.GenerateReport(orderId, this);
/* other code */
}
}
public class ReportGenerationService
{
public void GenerateReport(
int orderId,
CheckOutService checkOutService)
{
/* calls checkOutService when generation is completed */
}
}
这里,CheckOutService创建一个实例ReportGenerationService并将其自身作为参数传递给该实例。ReportGenerationService回调CheckOutService以通知它报告生成的结果。
Here, CheckOutService creates an instance of ReportGenerationService and passes itself to that instance as an argument. ReportGenerationService calls CheckOutService back to notify it about the result of the report generation.
就像过多的抽象层一样,循环依赖关系会在您尝试阅读和理解代码时增加巨大的认知负担。原因是循环依赖关系不会为您提供一个明确的起点,您可以从此开始探索解决方案。要理解一个类,您必须一次性阅读并理解其兄弟类的整个图。即使是一小组相互依赖的类也会很快变得难以掌握。
Just like an excessive number of abstraction layers, circular dependencies add tremendous cognitive load when you try to read and understand the code. The reason is that circular dependencies don’t give you a clear starting point from which you can begin exploring the solution. To understand just one class, you have to read and understand the whole graph of its siblings all at once. Even a small set of interdependent classes can quickly become too hard to grasp.
循环依赖也会影响测试。你经常需要借助接口和模拟来分割类图并隔离单个行为单元,而这在测试领域模型时同样是不可行的(更多内容请参见第5 章)。
Circular dependencies also interfere with testing. You often have to resort to interfaces and mocking in order to split the class graph and isolate a single unit of behavior, which, again, is a no-go when it comes to testing the domain model (more on that in chapter 5).
请注意,使用接口只能掩盖循环依赖的问题。如果你引入一个接口CheckOutService并使其ReportGenerationService依赖于该接口而不是具体类,那么在编译时就可以消除循环依赖(图 8.11),但循环在运行时仍然存在。即使虽然编译器不再将此类组合视为循环引用,但理解代码所需的认知负荷并没有变小。如果有的话,由于增加了接口,认知负荷反而增加了。
Note that the use of interfaces only masks the problem of circular dependencies. If you introduce an interface for CheckOutService and make ReportGenerationService depend on that interface instead of the concrete class, you remove the circular dependency at compile time (figure 8.11), but the cycle still persists at runtime. Even though the compiler no longer regards this class composition as a circular reference, the cognitive load required to understand the code doesn’t become any smaller. If anything, it increases due to the additional interface.
处理循环依赖的更好方法是摆脱它们。重构ReportGenerationService使其既不依赖于接口也不CheckOutService依赖于ICheckOutService接口,并将ReportGenerationService其工作结果作为普通值返回,而不是调用CheckOutService:
A better approach to handle circular dependencies is to get rid of them. Refactor ReportGenerationService such that it depends on neither CheckOutService nor the ICheckOutService interface, and make ReportGenerationService return the result of its work as a plain value instead of calling CheckOutService:
公共类CheckOutService
{
公共无效CheckOut(int orderId)
{
var 服务 = 新的 ReportGenerationService();
报告报告 = 服务.生成报告(orderId);
/* 其他工作 */
}
}
公共类ReportGenerationService
{
公共报告生成报告(int orderId)
{
/* ... */
}
}public class CheckOutService
{
public void CheckOut(int orderId)
{
var service = new ReportGenerationService();
Report report = service.GenerateReport(orderId);
/* other work */
}
}
public class ReportGenerationService
{
public Report GenerateReport(int orderId)
{
/* ... */
}
}
消除代码库中的所有循环依赖关系几乎是不可能的。但即便如此,您也可以通过使剩余的相互依赖类图尽可能小来将损害降至最低。
It’s rarely possible to eliminate all circular dependencies in your code base. But even then, you can minimize the damage by making the remaining graphs of interdependent classes as small as possible.
您可能还记得第 3 章的内容,测试中存在多个安排、行为或断言部分是一种代码异味。这表明该测试检查了多个行为单元,这反过来又妨碍了测试的可维护性。例如,如果您有两个相关用例(例如,用户注册和用户删除)可能很想在单个集成测试中检查这两个用例。这样的测试可以具有以下结构:
As you might remember from chapter 3, having more than one arrange, act, or assert section in a test is a code smell. It’s a sign that this test checks multiple units of behavior, which, in turn, hinders the test’s maintainability. For example, if you have two related use cases—say, user registration and user deletion—it might be tempting to check both of these use cases in a single integration test. Such a test could have the following structure:
这种方法很有吸引力,因为用户状态自然地相互流动,并且第一个动作(注册用户)可以同时作为后续动作(删除用户)的安排阶段。问题是,这样的测试会失去重点,并且很快就会变得过于臃肿。
This approach is compelling because the user states naturally flow from one another, and the first act (registering a user) can simultaneously serve as an arrange phase for the subsequent act (user deletion). The problem is that such tests lose focus and can quickly become too bloated.
最好将测试拆分,将每个行为提取到自己的测试中。这看起来似乎是不必要的工作(毕竟,为什么要创建两个测试,一个测试就足够了?),但从长远来看,这项工作是值得的。让每个测试都专注于一个行为单元,可以使这些测试更容易理解和修改。
It’s best to split the test by extracting each act into a test of its own. It may seem like unnecessary work (after all, why create two tests where one would suffice?), but this work pays off in the long run. Having each test focus on a single unit of behavior makes those tests easier to understand and modify when necessary.
此准则的例外情况是测试使用难以达到理想状态的进程外依赖项。例如,假设注册用户会导致在外部银行系统中创建一个银行账户。银行为您的组织提供了一个沙盒,您想在端到端测试中使用该沙盒。问题是沙盒太慢,或者银行限制了您可以对该沙盒进行的调用次数。在这种情况下,将多个操作组合成一个测试会很有帮助,从而减少与有问题的进程外依赖项的交互次数。
The exception to this guideline is tests working with out-of-process dependencies that are hard to bring to a desirable state. Let’s say for example that registering a user results in creating a bank account in an external banking system. The bank has provisioned a sandbox for your organization, and you want to use that sandbox in an end-to-end test. The problem is that the sandbox is too slow, or maybe the bank limits the number of calls you can make to that sandbox. In such a scenario, it becomes beneficial to combine multiple acts into a single test and thus reduce the number of interactions with the problematic out-of-process dependency.
难以管理的进程外依赖关系是编写包含多个动作部分的测试的唯一合法理由。这就是为什么您永远不应该在单元测试中包含多个动作的原因——单元测试不适用于进程外依赖关系。即使是集成测试也很少应该包含多个动作。在实践中,多步骤测试几乎总是属于端到端测试类别。
Hard-to-manage out-of-process dependencies are the only legitimate reason to write a test with more than one act section. This is why you should never have multiple acts in a unit test—unit tests don’t work with out-of-process dependencies. Even integration tests should rarely have several acts. In practice, multistep tests almost always belong to the category of end-to-end tests.
日志记录是一个灰色区域,在测试时如何处理它并不明显。这是一个复杂的主题,我将分为以下问题:
Logging is a gray area, and it isn’t obvious what to do with it when it comes to testing. This is a complex topic that I’ll split into the following questions:
我们将使用我们的示例 CRM 项目作为示例。
We’ll use our sample CRM project as an example.
日志记录是一项跨领域的功能,您可以在代码库的任何部分使用它。以下是User类中的日志记录示例。
Logging is a cross-cutting functionality, which you can require in any part of your code base. Here’s an example of logging in the User class.
公共类用户
{
公共无效更改电子邮件(字符串新电子邮件,公司公司)
{
_logger.信息( 1
$"将用户 {UserId} 的电子邮件更改为 {newEmail}");
前提条件.需要(CanChangeEmail()==null);
如果(电子邮件==新电子邮件)
返回;
用户类型新类型 = 公司.IsEmailCorporate(新电子邮件)
? 用户类型.员工
:用户类型.客户;
如果(类型!=新类型)
{
int delta = newType == UserType.Employee ? 1 : -1;
公司.改变员工人数(delta);
_logger.Info( 2
$"用户 {UserId} 类型已从 {Type} 更改 为 { newType
}"); 2
}
电子邮件=新电子邮件;
类型=新类型;
EmailChangedEvents.添加(新EmailChangedEvent(UserId,newEmail));
_logger.信息( 3
$“用户 {UserId} 的电子邮件已更改”);
}
}public class User
{
public void ChangeEmail(string newEmail, Company company)
{
_logger.Info( 1
$"Changing email for user {UserId} to {newEmail}");
Precondition.Requires(CanChangeEmail() == null);
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
_logger.Info( 2
$"User {UserId} changed type " + 2
$"from {Type} to {newType}"); 2
}
Email = newEmail;
Type = newType;
EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));
_logger.Info( 3
$"Email is changed for user {UserId}");
}
}
该类User在日志文件中记录ChangeEmail方法的每个开始和结束,以及用户类型的变化。您应该测试此功能吗?
The User class records in a log file each beginning and ending of the ChangeEmail method, as well as the change of the user type. Should you test this functionality?
一方面,日志记录会生成有关应用程序行为的重要信息。但另一方面,日志记录无处不在,因此很难判断此功能是否值得进行额外的、相当重要的测试工作。是否应该测试日志记录这个问题的答案归结为:日志记录是应用程序可观察行为的一部分,还是实现细节?
On the one hand, logging generates important information about the application’s behavior. But on the other hand, logging can be so ubiquitous that it’s not obvious whether this functionality is worth the additional, quite significant, testing effort. The answer to the question of whether you should test logging comes down to this: Is logging part of the application’s observable behavior, or is it an implementation detail?
从这个意义上讲,它与其他功能并无不同。日志记录最终会导致进程外依赖项(例如文本文件或数据库)产生副作用。如果这些副作用是要由您的客户、应用程序的客户端或除开发人员以外的任何人观察到的,那么日志记录就是可观察到的行为,因此必须进行测试。如果唯一的受众是开发人员,那么它就是一个可以自由修改而不会被任何人注意到的实现细节,在这种情况下,它不应该进行测试。
In that sense, it isn’t different from any other functionality. Logging ultimately results in side effects in an out-of-process dependency such as a text file or a database. If these side effects are meant to be observed by your customer, the application’s clients, or anyone else other than the developers themselves, then logging is an observable behavior and thus must be tested. If the only audience is the developers, then it’s an implementation detail that can be freely modified without anyone noticing, in which case it shouldn’t be tested.
例如,如果您编写了一个日志库,那么该库生成的日志就是其可观察行为中最重要的(也是唯一的)部分。另一个例子是业务人员坚持记录关键应用程序工作流。在这种情况下,日志也成为业务需求,因此必须通过测试来覆盖。然而,在后一个例子中,您可能还会为开发人员单独设置日志记录。
For example, if you write a logging library, then the logs this library produces are the most important (and the only) part of its observable behavior. Another example is when business people insist on logging key application workflows. In this case, logs also become a business requirement and thus have to be covered by tests. However, in the latter example, you might also have separate logging just for developers.
Steve Freeman 和 Nat Pryce 在他们的书《Growing Object-Oriented Software, Guided by Tests》(Addison-Wesley Professional,2009)中将这两种类型的日志记录称为支持日志记录和诊断日志记录:
Steve Freeman and Nat Pryce, in their book Growing Object-Oriented Software, Guided by Tests (Addison-Wesley Professional, 2009), call these two types of logging support logging and diagnostic logging:
由于日志记录涉及进程外依赖项,因此在测试它时,适用的规则与涉及进程外依赖项的任何其他功能相同。您需要使用模拟来验证应用程序与日志存储之间的交互。
Because logging involves out-of-process dependencies, when it comes to testing it, the same rules apply as with any other functionality that touches out-of-process dependencies. You need to use mocks to verify interactions between your application and the log storage.
但不要只是模拟ILogger接口。因为支持日志记录是一项业务需求,所以请在代码库中明确反映该需求。创建一个特殊DomainLogger类,在其中明确列出业务所需的所有支持日志记录;验证与该类的交互,而不是原始的ILogger。
But don’t just mock out the ILogger interface. Because support logging is a business requirement, reflect that requirement explicitly in your code base. Create a special DomainLogger class where you explicitly list all the support logging needed for the business; verify interactions with that class instead of the raw ILogger.
例如,假设业务人员要求您记录用户类型的所有更改,但方法开头和结尾的记录只是为了调试目的。下一个清单显示了User引入DomainLogger类之后的类。
For example, let’s say that business people require you to log all changes of the users’ types, but the logging at the beginning and the end of the method is there just for debugging purposes. The next listing shows the User class after introducing a DomainLogger class.
公共无效更改电子邮件(字符串新电子邮件,公司公司)
{
_logger.信息( 1
$"将用户 {UserId} 的电子邮件更改为 {newEmail}");
前提条件.需要(CanChangeEmail()==null);
如果(电子邮件==新电子邮件)
返回;
用户类型新类型 = 公司.IsEmailCorporate(新电子邮件)
? 用户类型.员工
:用户类型.客户;
如果(类型!=新类型)
{
int delta = newType == UserType.Employee ? 1 : -1;
公司.改变员工人数(delta);
_domainLogger.UserTypeHasChanged( 2
用户 ID,类型,新类型); 2
}
电子邮件=新电子邮件;
类型=新类型;
EmailChangedEvents.添加(新EmailChangedEvent(UserId,newEmail));
_logger.信息( 3
$“用户 {UserId} 的电子邮件已更改”);
}public void ChangeEmail(string newEmail, Company company)
{
_logger.Info( 1
$"Changing email for user {UserId} to {newEmail}");
Precondition.Requires(CanChangeEmail() == null);
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
_domainLogger.UserTypeHasChanged( 2
UserId, Type, newType); 2
}
Email = newEmail;
Type = newType;
EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));
_logger.Info( 3
$"Email is changed for user {UserId}");
}
诊断日志记录仍使用旧版本logger(类型为ILogger),但支持日志记录现在使用domainLogger类型为的新实例IDomainLogger。以下清单显示了的实现IDomainLogger。
The diagnostic logging still uses the old logger (which is of type ILogger), but the support logging now uses the new domainLogger instance of type IDomainLogger. The following listing shows the implementation of IDomainLogger.
公共类DomainLogger:IDomainLogger
{
私有只读ILogger _logger;
公共DomainLogger(ILogger记录器)
{
_logger = 记录器;
}
公共无效UserTypeHasChanged(
int 用户 ID,旧用户类型,新用户类型)
{
_logger.信息(
$"用户 {userId} 更改了类型 " +
$"从 {oldType} 到 {newType}");
}
}public class DomainLogger : IDomainLogger
{
private readonly ILogger _logger;
public DomainLogger(ILogger logger)
{
_logger = logger;
}
public void UserTypeHasChanged(
int userId, UserType oldType, UserType newType)
{
_logger.Info(
$"User {userId} changed type " +
$"from {oldType} to {newType}");
}
}
DomainLogger工作于ILogger:它使用领域语言来声明业务所需的特定日志条目,从而使支持日志记录更容易理解和维护。事实上,这种实现与结构化日志的概念非常相似,这使得日志文件的后处理和分析具有很大的灵活性。
DomainLogger works on top of ILogger: it uses the domain language to declare specific log entries required by the business, thus making support logging easier to understand and maintain. In fact, this implementation is very similar to the concept of structured logging, which enables great flexibility when it comes to log file post-processing and analysis.
结构化日志记录是一种日志记录技术,其中捕获日志数据与呈现数据分离。传统日志记录使用简单文本。类似以下调用
Structured logging is a logging technique where capturing log data is decoupled from the rendering of that data. Traditional logging works with simple text. A call like
logger.Info("用户ID是" + 12);logger.Info("User Id is " + 12);
首先形成一个字符串,然后将该字符串写入日志存储。这种方法的问题在于,由于缺乏结构,生成的日志文件很难分析。例如,很难看到有多少特定类型的消息以及其中有多少与特定用户 ID 相关。您需要使用(甚至编写自己的)特殊工具来实现这一点。
first forms a string and then writes that string to a log storage. The problem with this approach is that the resulting log files are hard to analyze due to the lack of structure. For example, it’s not easy to see how many messages of a particular type there are and how many of those relate to a specific user ID. You’d need to use (or even write your own) special tooling for that.
另一方面,结构化日志记录为日志存储引入了结构。结构化日志记录库的使用表面上看起来类似:
On the other hand, structured logging introduces structure to your log storage. The use of a structured logging library looks similar on the surface:
logger.Info("用户ID为{UserId}",12);logger.Info("User Id is {UserId}", 12);
但其底层行为却大不相同。在后台,该方法计算消息模板的哈希值(为了节省空间,消息本身存储在查找存储中),并将该哈希值与输入参数相结合,形成一组捕获的数据。下一步是渲染这些数据。您仍然可以拥有一个平面日志文件,就像传统的日志记录一样,但这只是一种可能的渲染方式。您还可以配置日志记录库,将捕获的数据渲染为 JSON 或 CSV 文件,这样更易于分析(图 8.12)。
But its underlying behavior differs significantly. Behind the scenes, this method computes a hash of the message template (the message itself is stored in a lookup storage for space efficiency) and combines that hash with the input parameters to form a set of captured data. The next step is the rendering of that data. You can still have a flat log file, as with traditional logging, but that’s just one possible rendering. You could also configure the logging library to render the captured data as a JSON or a CSV file, where it would be easier to analyze (figure 8.12).
DomainLogger清单 8.5中的代码本身并不是一个结构化的记录器,但它的运行方式是一样的。再看一下这个方法:
DomainLogger in listing 8.5 isn’t a structured logger per se, but it operates in the same spirit. Look at this method once again:
公共无效UserTypeHasChanged(
int 用户 ID,旧用户类型,新用户类型)
{
_logger.信息(
$"用户 {userId} 更改了类型 " +
$"从 {oldType} 到 {newType}");
}public void UserTypeHasChanged(
int userId, UserType oldType, UserType newType)
{
_logger.Info(
$"User {userId} changed type " +
$"from {oldType} to {newType}");
}
您可以将其视为UserTypeHasChanged()消息模板的哈希。该哈希与userId、oldType和newType参数一起构成了日志数据。该方法的实现将日志数据呈现为平面日志文件。您还可以通过将日志数据写入 JSON 或 CSV 文件来轻松创建其他呈现。
You can view UserTypeHasChanged() as the message template’s hash. Together with the userId, oldType, and newType parameters, that hash forms the log data. The method’s implementation renders the log data into a flat log file. And you can easily create additional renderings by also writing the log data into a JSON or a CSV file.
正如我之前提到的,DomainLogger表示进程外依赖项——日志存储。这带来了一个问题:User现在与该依赖项交互,从而违反了业务逻辑与进程外依赖项通信之间的分离。 的使用DomainLogger已转变User为过于复杂的代码类别,使其更难测试和维护(有关代码类别的更多详细信息,请参阅第 7 章)。
As I mentioned earlier, DomainLogger represents an out-of-process dependency—the log storage. This poses a problem: User now interacts with that dependency and thus violates the separation between business logic and communication with out-of-process dependencies. The use of DomainLogger has transitioned User to the category of overcomplicated code, making it harder to test and maintain (refer to chapter 7 for more details about code categories).
这个问题可以像我们实现外部系统关于用户电子邮件更改的通知一样解决:借助领域事件(同样,请参阅第 7 章了解详细信息)。您可以引入单独的领域事件来跟踪用户类型的更改。然后,控制器会将这些更改转换为对的调用DomainLogger,如以下清单所示。
This problem can be solved the same way we implemented the notification of external systems about changed user emails: with the help of domain events (again, see chapter 7 for details). You can introduce a separate domain event to track changes in the user type. The controller will then convert those changes into calls to DomainLogger, as shown in the following listing.
公共无效更改电子邮件(字符串新电子邮件,公司公司)
{
_logger.信息(
$"将用户 {UserId} 的电子邮件更改为 {newEmail}");
前提条件.需要(CanChangeEmail()==null);
如果(电子邮件==新电子邮件)
返回;
用户类型新类型 = 公司.IsEmailCorporate(新电子邮件)
? 用户类型.员工
:用户类型.客户;
如果(类型!=新类型)
{
int delta = newType == UserType.Employee ? 1 : -1;
公司.改变员工人数(delta);
AddDomainEvent( 1
new UserTypeChangedEvent( 1
UserId, Type, newType)); 1
}
电子邮件=新电子邮件;
类型=新类型;
添加域事件(新EmailChangedEvent(UserId,newEmail));
_logger.Info($"用户 {UserId} 的电子邮件已更改");
}public void ChangeEmail(string newEmail, Company company)
{
_logger.Info(
$"Changing email for user {UserId} to {newEmail}");
Precondition.Requires(CanChangeEmail() == null);
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
AddDomainEvent( 1
new UserTypeChangedEvent( 1
UserId, Type, newType)); 1
}
Email = newEmail;
Type = newType;
AddDomainEvent(new EmailChangedEvent(UserId, newEmail));
_logger.Info($"Email is changed for user {UserId}");
}
注意现在有两个领域事件:UserTypeChangedEvent和EmailChangedEvent。它们都实现了相同的接口(IDomainEvent),因此可以存储在同一个集合中。
Notice that there are now two domain events: UserTypeChangedEvent and EmailChangedEvent. Both of them implement the same interface (IDomainEvent) and thus can be stored in the same collection.
控制器的外观如下。
And here is how the controller looks.
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
对象[] 用户数据 = _database.GetUserById(用户Id);
用户用户 = UserFactory.创建(用户数据);
字符串错误 = 用户.CanChangeEmail();
如果(错误!=空)
返回错误;
对象[] companyData = _database.GetCompany();
公司公司 = CompanyFactory.Create(companyData);
用户.更改电子邮件(新电子邮件,公司);
_数据库.保存公司(公司);
_数据库.保存用户(用户);
_eventDispatcher.Dispatch(用户.DomainEvents); 1
返回“OK”;
}public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_eventDispatcher.Dispatch(user.DomainEvents); 1
return "OK";
}
EventDispatcher是一个新的类,它将领域事件转换为对进程外依赖项的调用:
EventDispatcher is a new class that converts domain events into calls to out-of-process dependencies:
使用UserTypeChangedEvent恢复了两个职责之间的分离:域逻辑和与进程外依赖项的通信。测试支持日志记录现在与测试另一个非托管依赖项(消息总线)没有任何区别:
The use of UserTypeChangedEvent has restored the separation between the two responsibilities: domain logic and communication with out-of-process dependencies. Testing support logging now isn’t any different from testing the other unmanaged dependency, the message bus:
请注意,如果您需要在控制器中而不是在某个域类中支持日志记录,则无需使用域事件。您可能还记得第 7 章的内容,控制器协调域模型与进程外依赖项之间的协作。DomainLogger是此类依赖项之一,因此UserController可以直接使用该记录器。
Note that if you need to do support logging in the controller and not one of the domain classes, there’s no need to use domain events. As you may remember from chapter 7, controllers orchestrate the collaboration between the domain model and out-of-process dependencies. DomainLogger is one of such dependencies, and thus UserController can use that logger directly.
还请注意,我没有改变类User进行诊断日志记录的方式。User仍然logger在方法的开始和结束时直接使用实例ChangeEmail。这是设计使然。诊断日志记录仅供开发人员使用;您不需要对此功能进行单元测试,因此不必将其排除在域模型之外。
Also notice that I didn’t change the way the User class does diagnostic logging. User still uses the logger instance directly in the beginning and at the end of its ChangeEmail method. This is by design. Diagnostic logging is for developers only; you don’t need to unit test this functionality and thus don’t have to keep it out of the domain model.
不过,尽可能避免使用诊断日志记录User或其他域类。我将在下一节中解释原因。
Still, refrain from the use of diagnostic logging in User or other domain classes when possible. I explain why in the next section.
另一个重要问题是最佳日志记录量。多少日志记录才足够?支持日志记录在这里是不可能的,因为它是一项业务需求。不过,您可以控制诊断日志记录。
Another important question is about the optimum amount of logging. How much logging is enough? Support logging is out of the question here because it’s a business requirement. You do have control over diagnostic logging, though.
不要过度使用诊断日志记录,这一点很重要,原因如下:
It’s important not to overuse diagnostic logging, for the following two reasons:
尽量不要在域模型中使用诊断日志记录。在大多数情况下,您可以安全地将该日志记录从域类移至控制器。即使这样,也只有在需要调试某些内容时才暂时使用诊断日志记录。调试完成后将其删除。理想情况下,您应该仅对未处理的异常使用诊断日志记录。
Try not to use diagnostic logging in the domain model at all. In most cases, you can safely move that logging from domain classes to controllers. And even then, resort to diagnostic logging only temporarily when you need to debug something. Remove it once you finish debugging. Ideally, you should use diagnostic logging for unhandled exceptions only.
最后,最后一个问题是如何在代码中传递记录器实例。解决这些实例的一种方法是使用静态方法,如下面的清单所示。
Finally, the last question is how to pass logger instances in the code. One way to resolve these instances is using static methods, as shown in the following listing.
公共类用户
{
私有静态只读ILogger _logger = 1
LogManager.GetLogger(typeof(User)); 1
公共无效更改电子邮件(字符串新电子邮件,公司公司)
{
_logger.信息(
$"将用户 {UserId} 的电子邮件更改为 {newEmail}");
/* ... */
_logger.Info($"用户 {UserId} 的电子邮件已更改");
}
}public class User
{
private static readonly ILogger _logger = 1
LogManager.GetLogger(typeof(User)); 1
public void ChangeEmail(string newEmail, Company company)
{
_logger.Info(
$"Changing email for user {UserId} to {newEmail}");
/* ... */
_logger.Info($"Email is changed for user {UserId}");
}
}
Steven van Deursen 和 Mark Seeman 在他们的著作《依赖注入原则、实践、模式》(Manning Publications,2018 年)中将这种依赖获取称为环境上下文。这是一种反模式。他们的两个论点是
Steven van Deursen and Mark Seeman, in their book Dependency Injection Principles, Practices, Patterns (Manning Publications, 2018), call this type of dependency acquisition ambient context. This is an anti-pattern. Two of their arguments are that
我完全同意这种分析。但对我来说,环境上下文的主要缺点是它掩盖了代码中的潜在问题。如果将记录器显式地注入域类变得非常不方便,以至于您不得不求助于环境上下文,那么这肯定是麻烦的征兆。您要么记录太多,要么使用太多间接层。无论如何,环境上下文都不是解决方案。相反,要解决问题的根本原因。
I fully agree with this analysis. To me, though, the main drawback of ambient context is that it masks potential problems in code. If injecting a logger explicitly into a domain class becomes so inconvenient that you have to resort to ambient context, that’s a certain sign of trouble. You either log too much or use too many layers of indirection. In any case, ambient context is not a solution. Instead, tackle the root cause of the problem.
以下清单显示了一种显式注入记录器的方法:作为方法参数。另一种方法是通过类构造函数。
The following listing shows one way to explicitly inject the logger: as a method argument. Another way is through the class constructor.
公共无效更改电子邮件(
字符串 newEmail, 1
公司 company, 1
ILogger logger) 1
{
logger.信息(
$"将用户 {UserId} 的电子邮件更改为 {newEmail}");
/* ... */
logger.Info($"用户 {UserId} 的电子邮件已更改");
}public void ChangeEmail(
string newEmail, 1
Company company, 1
ILogger logger) 1
{
logger.Info(
$"Changing email for user {UserId} to {newEmail}");
/* ... */
logger.Info($"Email is changed for user {UserId}");
}
通过以下视角来查看与所有进程外依赖项的通信:此通信是应用程序可观察行为的一部分还是实现细节。日志存储在这方面没有什么不同。如果日志可由非程序员观察,则模拟日志记录功能;否则不要测试它。在下一章中,我们将深入探讨模拟主题及其相关的最佳实践。
View communications with all out-of-process dependencies through the lens of whether this communication is part of the application’s observable behavior or an implementation detail. The log storage isn’t any different in that regard. Mock logging functionality if the logs are observable by non-programmers; don’t test it otherwise. In the next chapter, we’ll dive deeper into the topic of mocking and best practices related to it.
您可能还记得第 5 章的内容,模拟是一种测试替身,有助于模拟和检查被测系统与其依赖项之间的交互。您可能还记得第 8 章的内容,模拟应仅应用于非托管依赖项(外部应用程序可以观察到与此类依赖项的交互)。将模拟用于其他任何用途都会导致测试脆弱(测试缺乏对重构的抵抗力)。对于模拟,遵守这一准则将使您获得成功的三分之二。
As you might remember from chapter 5, a mock is a test double that helps to emulate and examine interactions between the system under test and its dependencies. As you might also remember from chapter 8, mocks should only be applied to unmanaged dependencies (interactions with such dependencies are observable by external applications). Using mocks for anything else results in brittle tests (tests that lack the metric of resistance to refactoring). When it comes to mocks, adhering to this one guideline will get you about two-thirds of the way to success.
本章将介绍其余的指导原则,这些指导原则将帮助您开发具有最大价值的集成测试,方法是最大限度地提高模拟对重构的抵抗力和对回归的保护。我将首先展示模拟的典型用法,描述其缺点,然后演示如何克服这些缺点。
This chapter shows the remaining guidelines that will help you develop integration tests that have the greatest possible value by maxing out mocks’ resistance to refactoring and protection against regressions. I’ll first show a typical use of mocks, describe its drawbacks, and then demonstrate how you can overcome those drawbacks.
将模拟的使用限制在非托管依赖项上很重要,但这只是最大化模拟价值的第一步。本主题最好通过示例来解释,因此我将继续使用前面章节中的 CRM 系统作为示例项目。我将提醒您它的功能并展示我们最终得到的集成测试。之后,您将看到如何在模拟方面改进该测试。
It’s important to limit the use of mocks to unmanaged dependencies, but that’s only the first step on the way to maximizing the value of mocks. This topic is best explained with an example, so I’ll continue using the CRM system from earlier chapters as a sample project. I’ll remind you of its functionality and show the integration test we ended up with. After that, you’ll see how that test can be improved with regard to mocking.
您可能还记得,CRM 系统目前仅支持一种用例:更改用户的电子邮件。以下清单显示了我们离开控制器的位置。
As you might recall, the CRM system currently supports only one use case: changing a user’s email. The following listing shows where we left off with the controller.
公共类用户控制器
{
私有只读数据库_database;
私有只读EventDispatcher _eventDispatcher;
公共用户控制器(
数据库数据库,
IMessageBus 消息总线,
IDomainLogger 域日志记录器)
{
_database = 数据库;
_eventDispatcher = 新的 EventDispatcher(
消息总线,域记录器);
}
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
对象[] 用户数据 = _database.GetUserById(用户Id);
用户用户 = UserFactory.创建(用户数据);
字符串错误 = 用户.CanChangeEmail();
如果(错误!=空)
返回错误;
对象[] companyData = _database.GetCompany();
公司公司 = CompanyFactory.Create(companyData);
用户.更改电子邮件(新电子邮件,公司);
_数据库.保存公司(公司);
_数据库.保存用户(用户);
_eventDispatcher.Dispatch(用户.域事件);
返回“OK”;
}
}public class UserController
{
private readonly Database _database;
private readonly EventDispatcher _eventDispatcher;
public UserController(
Database database,
IMessageBus messageBus,
IDomainLogger domainLogger)
{
_database = database;
_eventDispatcher = new EventDispatcher(
messageBus, domainLogger);
}
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_eventDispatcher.Dispatch(user.DomainEvents);
return "OK";
}
}
请注意,不再有任何诊断日志记录,但支持日志记录(IDomainLogger接口)仍然存在(有关详细信息,请参阅第 8 章)。此外,清单 9.1引入了一个新类:EventDispatcher。它将由生成的域事件转换为领域模型转变为对非托管依赖项的调用(这是控制器以前自行完成的事情),如下所示。
Note that there’s no longer any diagnostic logging, but support logging (the IDomainLogger interface) is still in place (see chapter 8 for more details). Also, listing 9.1 introduces a new class: the EventDispatcher. It converts domain events generated by the domain model into calls to unmanaged dependencies (something that the controller previously did by itself), as shown next.
公共类事件调度器
{
私有只读IMessageBus _messageBus;
私有只读IDomainLogger _domainLogger;
公共事件调度器(
IMessageBus 消息总线,
IDomainLogger 域日志记录器)
{
_domainLogger = 域日志记录器;
_消息总线 = 消息总线;
}
公共无效调度(列表<IDomainEvent>事件)
{
foreach(IDomainEvent ev 在事件中)
{
派遣(ev);
}
}
私有无效调度(IDomainEvent ev)
{
开关(EV)
{
案例 EmailChangedEvent emailChangedEvent:
_messageBus.发送电子邮件更改消息(
emailChangedEvent.UserId,
电子邮件更改事件.新电子邮件);
休息;
案例UserTypeChangedEvent用户类型更改事件:
_domainLogger.UserType已改变(
用户类型改变事件.UserId,
用户类型改变事件.旧类型,
用户类型改变事件.新类型);
休息;
}
}
}public class EventDispatcher
{
private readonly IMessageBus _messageBus;
private readonly IDomainLogger _domainLogger;
public EventDispatcher(
IMessageBus messageBus,
IDomainLogger domainLogger)
{
_domainLogger = domainLogger;
_messageBus = messageBus;
}
public void Dispatch(List<IDomainEvent> events)
{
foreach (IDomainEvent ev in events)
{
Dispatch(ev);
}
}
private void Dispatch(IDomainEvent ev)
{
switch (ev)
{
case EmailChangedEvent emailChangedEvent:
_messageBus.SendEmailChangedMessage(
emailChangedEvent.UserId,
emailChangedEvent.NewEmail);
break;
case UserTypeChangedEvent userTypeChangedEvent:
_domainLogger.UserTypeHasChanged(
userTypeChangedEvent.UserId,
userTypeChangedEvent.OldType,
userTypeChangedEvent.NewType);
break;
}
}
}
最后,以下清单显示了集成测试。此测试检查所有进程外依赖项(托管和非托管)。
Finally, the following listing shows the integration test. This test goes through all out-of-process dependencies (both managed and unmanaged).
[事实]
公共无效 Changing_email_from_corporate_to_non_corporate()
{
// 安排
var db = 新数据库(连接字符串);
用户 user = CreateUser("user@mycorp.com", UserType.Employee, db);
创建公司(“mycorp.com”,1,db);
var messageBusMock = new Mock<IMessageBus>(); 1
var loggerMock = new Mock<IDomainLogger>(); 1
var sut = new UserController(
db,messageBusMock.Object,loggerMock.Object);
// 行为
字符串结果 = sut.ChangeEmail(用户.UserId,“new@gmail.com”);
// 断言
Assert.Equal("OK",结果);
对象[] 用户数据 = db.GetUserById(用户.UserId);
用户 userFromDb = UserFactory.Create(userData);
断言.Equal("new@gmail.com", userFromDb.Email);
断言.等于(用户类型.客户,用户来自数据库.类型);
对象[] companyData = db.GetCompany();
公司 companyFromDb = CompanyFactory.Create(companyData);
断言.等于(0,companyFromDb.NumberOfEmployees);
messageBusMock.Verify( 2
x => x.SendEmailChangedMessage( 2
用户.UserId, "new@gmail.com"), 2
Times.Once); 2
loggerMock.Verify( 2
x => x.UserTypeHasChanged( 2
用户.UserId, 2
用户类型.Employee, 2
用户类型.Customer), 2
Times.Once); 2
}[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
// Arrange
var db = new Database(ConnectionString);
User user = CreateUser("user@mycorp.com", UserType.Employee, db);
CreateCompany("mycorp.com", 1, db);
var messageBusMock = new Mock<IMessageBus>(); 1
var loggerMock = new Mock<IDomainLogger>(); 1
var sut = new UserController(
db, messageBusMock.Object, loggerMock.Object);
// Act
string result = sut.ChangeEmail(user.UserId, "new@gmail.com");
// Assert
Assert.Equal("OK", result);
object[] userData = db.GetUserById(user.UserId);
User userFromDb = UserFactory.Create(userData);
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);
object[] companyData = db.GetCompany();
Company companyFromDb = CompanyFactory.Create(companyData);
Assert.Equal(0, companyFromDb.NumberOfEmployees);
messageBusMock.Verify( 2
x => x.SendEmailChangedMessage( 2
user.UserId, "new@gmail.com"), 2
Times.Once); 2
loggerMock.Verify( 2
x => x.UserTypeHasChanged( 2
user.UserId, 2
UserType.Employee, 2
UserType.Customer), 2
Times.Once); 2
}
这个测试模拟了两个非托管依赖项:IMessageBus和IDomainLogger。我将重点介绍IMessageBus第一个。我们将IDomainLogger在本章后面讨论。
This test mocks out two unmanaged dependencies: IMessageBus and IDomainLogger. I’ll focus on IMessageBus first. We’ll discuss IDomainLogger later in this chapter.
让我们讨论一下为什么清单 9.3中的集成测试所使用的模拟在防止回归和抵抗重构方面并不理想,以及我们如何解决这个问题。
Let’s discuss why the mocks used by the integration test in listing 9.3 aren’t ideal in terms of their protection against regressions and resistance to refactoring and how we can fix that.
模拟时,始终遵循以下准则:验证与系统边缘的非托管依赖项的交互。
When mocking, always adhere to the following guideline: verify interactions with unmanaged dependencies at the very edges of your system.
清单 9.3messageBusMock中的问题是接口不在系统的边缘。看看该接口的实现。IMessageBus
The problem with messageBusMock in listing 9.3 is that the IMessageBus interface doesn’t reside at the system’s edge. Look at that interface’s implementation.
公共接口 IMessageBus
{
void SendEmailChangedMessage(int userId,string newEmail);
}
公共类消息总线:IMessageBus
{
私有只读IBus _bus;
公共无效SendEmailChangedMessage(
int 用户 ID,字符串新电子邮件)
{
_bus.Send("类型:用户电子邮件已更改;" +
$"ID: {用户ID}; " +
$“新邮件:{新邮件}”);
}
}
公共接口 IBus
{
无效发送(字符串消息);
}public interface IMessageBus
{
void SendEmailChangedMessage(int userId, string newEmail);
}
public class MessageBus : IMessageBus
{
private readonly IBus _bus;
public void SendEmailChangedMessage(
int userId, string newEmail)
{
_bus.Send("Type: USER EMAIL CHANGED; " +
$"Id: {userId}; " +
$"NewEmail: {newEmail}");
}
}
public interface IBus
{
void Send(string message);
}
IMessageBus和接口(以及实现它们的类)都IBus属于我们项目的代码库。IBus是消息总线 SDK 库(由开发该消息总线的公司提供)之上的包装器。此包装器封装了非必要的技术细节(例如连接凭据),并公开了一个简洁的接口,用于将任意文本消息发送到总线。IMessageBus是 之上的包装器IBus;它定义特定于您的域的消息。IMessageBus帮助您将所有此类消息保存在一个位置并在整个应用程序中重复使用它们。
Both the IMessageBus and IBus interfaces (and the classes implementing them) belong to our project’s code base. IBus is a wrapper on top of the message bus SDK library (provided by the company that develops that message bus). This wrapper encapsulates non-essential technical details, such as connection credentials, and exposes a nice, clean interface for sending arbitrary text messages to the bus. IMessageBus is a wrapper on top of IBus; it defines messages specific to your domain. IMessageBus helps you keep all such messages in one place and reuse them across the application.
可以将IBus和IMessageBus接口合并在一起,但这不是最佳解决方案。这两个职责(隐藏外部库的复杂性并将所有应用程序消息保存在一个地方)最好分开。这与您在第 8 章ILogger中看到的和的情况相同。实现业务所需的特定日志记录功能,它通过在后台使用泛型来实现。IDomainLoggerIDomainLoggerILogger
It’s possible to merge the IBus and IMessageBus interfaces together, but that would be a suboptimal solution. These two responsibilities—hiding the external library’s complexity and holding all application messages in one place—are best kept separated. This is the same situation as with ILogger and IDomainLogger, which you saw in chapter 8. IDomainLogger implements specific logging functionality required by the business, and it does that by using the generic ILogger behind the scenes.
图 9.1显示了从六边形架构角度来看IBus和的位置:是控制器和消息总线之间类型链中的最后一环,而只是途中的一个中间步骤。IMessageBusIBusIMessageBus
Figure 9.1 shows where IBus and IMessageBus stand from a hexagonal architecture perspective: IBus is the last link in the chain of types between the controller and the message bus, while IMessageBus is only an intermediate step on the way.
使用模拟IBus而不是IMessageBus最大化模拟对回归的保护。您可能还记得第 4 章的内容,对回归的保护取决于测试期间执行的代码量。模拟与非托管依赖项通信的最后一种类型会增加集成测试所经历的类的数量,从而提高保护。这条准则也是您不想模拟的原因EventDispatcher。与相比,它距离系统边缘更远IMessageBus。
Mocking IBus instead of IMessageBus maximizes the mock’s protection against regressions. As you might remember from chapter 4, protection against regressions is a function of the amount of code that is executed during the test. Mocking the very last type that communicates with the unmanaged dependency increases the number of classes the integration test goes through and thus improves the protection. This guideline is also the reason you don’t want to mock EventDispatcher. It resides even further away from the edge of the system, compared to IMessageBus.
IMessageBus以下是将代码从改为后的集成测试。我省略了与清单 9.3IBus中没有变化的部分。
Here’s the integration test after retargeting it from IMessageBus to IBus. I’m omitting the parts that didn’t change from listing 9.3.
[事实]
公共无效 Changing_email_from_corporate_to_non_corporate()
{
var BusMock = new Mock<IBus>();
var messageBus = new MessageBus(busMock.Object); 1
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db,messageBus,loggerMock.Object);
/* ... */
busMock.验证(
x => x.发送(
“类型:用户电子邮件已更改;” + 2
$“Id:{user.UserId};” + 2
“新电子邮件:new@gmail.com”), 2
次.一次);
}[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
var busMock = new Mock<IBus>();
var messageBus = new MessageBus(busMock.Object); 1
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db, messageBus, loggerMock.Object);
/* ... */
busMock.Verify(
x => x.Send(
"Type: USER EMAIL CHANGED; " + 2
$"Id: {user.UserId}; " + 2
"NewEmail: new@gmail.com"), 2
Times.Once);
}
请注意,测试现在使用的是具体MessageBus类,而不是相应的IMessageBus接口。IMessageBus是具有单一实现的接口,并且,正如您从第 8 章所记得的那样,模拟是拥有此类接口的唯一合法理由。 因为我们不再模拟IMessageBus,所以可以删除此接口并将其用法替换为MessageBus。
Notice how the test now uses the concrete MessageBus class and not the corresponding IMessageBus interface. IMessageBus is an interface with a single implementation, and, as you’ll remember from chapter 8, mocking is the only legitimate reason to have such interfaces. Because we no longer mock IMessageBus, this interface can be deleted and its usages replaced with MessageBus.
还请注意清单 9.5中的测试如何检查发送到公交车的文本消息。将其与前一个版本进行比较:
Also notice how the test in listing 9.5 checks the text message sent to the bus. Compare it to the previous version:
messageBusMock.验证(
x => x.SendEmailChangedMessage(用户.UserId, “new@gmail.com”),
次.一次);messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
Times.Once);
验证对您编写的自定义类的调用和发送到外部系统的实际文本之间存在巨大差异。外部系统需要来自应用程序的文本消息,而不是对 之类的类的调用MessageBus。事实上,文本消息是唯一可在外部观察到的副作用;参与生成这些消息的类仅仅是实现细节。因此,除了增强对回归的保护之外,验证系统边缘的交互还可以提高对重构的抵抗力。由此产生的测试不太容易受到潜在的误报的影响;无论进行什么重构,只要消息的结构得以保留,此类测试就不会变红。
There’s a huge difference between verifying a call to a custom class that you wrote and the actual text sent to external systems. External systems expect text messages from your application, not calls to classes like MessageBus. In fact, text messages are the only side effect observable externally; classes that participate in producing those messages are mere implementation details. Thus, in addition to the increased protection against regressions, verifying interactions at the very edges of your system also improves resistance to refactoring. The resulting tests are less exposed to potential false positives; no matter what refactorings take place, such tests won’t turn red as long as the message’s structure is preserved.
这里使用的机制与单元测试相比,集成和端到端测试具有更强的抗重构能力。它们与代码库的分离程度更高,因此在低级重构期间不会受到太大影响。
The same mechanism is at play here as the one that gives integration and end-to-end tests additional resistance to refactoring compared to unit tests. They are more detached from the code base and, therefore, aren’t affected as much during low-level refactorings.
对非托管依赖项的调用在离开应用程序之前会经历几个阶段。选择最后一个这样的阶段。这是确保与外部系统向后兼容的最佳方式,而这正是 mock 可以帮助您实现的目标。
A call to an unmanaged dependency goes through several stages before it leaves your application. Pick the last such stage. It is the best way to ensure backward compatibility with external systems, which is the goal that mocks help you achieve.
您可能还记得第 5 章的内容,间谍是测试替身的变体,其作用与模拟相同。唯一的区别是间谍是手动编写的,而模拟是借助模拟框架创建的。事实上,间谍通常被称为手写模拟。
As you may remember from chapter 5, a spy is a variation of a test double that serves the same purpose as a mock. The only difference is that spies are written manually, whereas mocks are created with the help of a mocking framework. Indeed, spies are often called handwritten mocks.
事实证明,当涉及到驻留在系统边缘的类时,间谍比模拟更胜一筹。间谍可帮助您在断言阶段重用代码,从而减少测试的大小并提高可读性。下一个清单显示了在 之上运行的间谍的示例IBus。
It turns out that, when it comes to classes residing at the system edges, spies are superior to mocks. Spies help you reuse code in the assertion phase, thereby reducing the test’s size and improving readability. The next listing shows an example of a spy that works on top of IBus.
公共接口 IBus
{
无效发送(字符串消息);
}
公共类 BusSpy: IBus
{
私有列表 <string> _sentMessages = 1
新列表 <string>(); 1
公共无效发送(字符串消息)
{
_sentMessages.添加(消息); 1
}
公共 BusSpy ShouldSendNumberOfMessages(int 数字)
{
断言.等于(数字,_sentMessages.Count);
返回这个;
}
公共 BusSpy WithEmailChangedMessage(int userId,string newEmail)
{
字符串消息 = “类型:用户电子邮件已更改;” +
$"ID: {用户ID}; " +
$"新邮件: {新邮件}";
Assert.Contains( 2
_sentMessages, x => x == message); 2
返回这个;
}
}public interface IBus
{
void Send(string message);
}
public class BusSpy : IBus
{
private List<string> _sentMessages = 1
new List<string>(); 1
public void Send(string message)
{
_sentMessages.Add(message); 1
}
public BusSpy ShouldSendNumberOfMessages(int number)
{
Assert.Equal(number, _sentMessages.Count);
return this;
}
public BusSpy WithEmailChangedMessage(int userId, string newEmail)
{
string message = "Type: USER EMAIL CHANGED; " +
$"Id: {userId}; " +
$"NewEmail: {newEmail}";
Assert.Contains( 2
_sentMessages, x => x == message); 2
return this;
}
}
以下清单是集成测试的新版本。再次强调,我仅展示相关部分。
The following listing is a new version of the integration test. Again, I’m showing only the relevant parts.
[事实]
公共无效 Changing_email_from_corporate_to_non_corporate()
{
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db,messageBus,loggerMock.Object);
/* ... */
busSpy.应该发送的消息数量(1)
.WithEmailChangedMessage(用户.UserId,“new@gmail.com”);
}[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db, messageBus, loggerMock.Object);
/* ... */
busSpy.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");
}
由于BusSpy提供了流畅的接口,现在验证与消息总线的交互变得简洁而富有表现力。借助该流畅的接口,您可以将多个断言链接在一起,从而形成连贯的、几乎是纯英文的句子。
Verifying the interactions with the message bus is now succinct and expressive, thanks to the fluent interface that BusSpy provides. With that fluent interface, you can chain together several assertions, thus forming cohesive, almost plain-English sentences.
您可以将 重命名BusSpy为BusMock。正如我之前提到的,mock 和 spy 之间的区别在于实现细节。不过,大多数程序员并不熟悉spy这个术语,因此将 spy 重命名为BusMock可以避免您的同事产生不必要的困惑。
You can rename BusSpy into BusMock. As I mentioned earlier, the difference between a mock and a spy is an implementation detail. Most programmers aren’t familiar with the term spy, though, so renaming the spy as BusMock can save your colleagues unnecessary confusion.
这里有合理的疑问:我们不是绕了一圈又回到了原点吗?清单 9.7中的测试版本看起来很像早期 mock 的版本IMessageBus:
There’s a reasonable question to be asked here: didn’t we just make a full circle and come back to where we started? The version of the test in listing 9.7 looks a lot like the earlier version that mocked IMessageBus:
messageBusMock.验证(
x => x.SendEmailChangedMessage( 1
用户.UserId, "new@gmail.com"), 1
次.一次); 2messageBusMock.Verify(
x => x.SendEmailChangedMessage( 1
user.UserId, "new@gmail.com"), 1
Times.Once); 2
这些断言相似,因为和都是BusSpy在MessageBus之上的包装器IBus。但两者之间有一个关键的区别:BusSpy是测试代码的一部分,而MessageBus属于生产代码。这个区别很重要,因为在测试中进行断言时,您不应该依赖生产代码。
These assertions are similar because both BusSpy and MessageBus are wrappers on top of IBus. But there’s a crucial difference between the two: BusSpy is part of the test code, whereas MessageBus belongs to the production code. This difference is important because you shouldn’t rely on the production code when making assertions in tests.
将您的测试视为审计员。优秀的审计员不会只听信被审计者的话;他们会仔细检查一切。间谍也是如此:它提供了一个独立的检查点,当消息结构发生变化时会发出警报。另一方面,模拟IMessageBus过于信任生产代码。
Think of your tests as auditors. A good auditor wouldn’t just take the auditee’s words at face value; they would double-check everything. The same is true with the spy: it provides an independent checkpoint that raises an alarm when the message structure is changed. On the other hand, a mock on IMessageBus puts too much trust in the production code.
之前验证了与 交互的模拟IMessageBus现在针对的是IBus,它位于系统的边缘。以下是集成测试中的当前模拟断言。
The mock that previously verified interactions with IMessageBus is now targeted at IBus, which resides at the system’s edge. Here are the current mock assertions in the integration test.
busSpy.ShouldSendNumberOfMessages(1) 1
.WithEmailChangedMessage( 1
user.UserId, "new@gmail.com"); 1
loggerMock.Verify( 2
x => x.UserTypeHasChanged( 2
用户.UserId, 2
用户类型.Employee, 2
用户类型.Customer), 2
Times.Once); 2busSpy.ShouldSendNumberOfMessages(1) 1
.WithEmailChangedMessage( 1
user.UserId, "new@gmail.com"); 1
loggerMock.Verify( 2
x => x.UserTypeHasChanged( 2
user.UserId, 2
UserType.Employee, 2
UserType.Customer), 2
Times.Once); 2
请注意,就像MessageBus是 之上的包装器一样IBus,DomainLogger也是 之上的包装器ILogger(有关详细信息,请参阅第 8 章)。测试是否也应该重新定位到ILogger,因为这个接口也位于应用程序边界?
Note that just as MessageBus is a wrapper on top of IBus, DomainLogger is a wrapper on top of ILogger (see chapter 8 for more details). Shouldn’t the test be retargeted at ILogger, too, because this interface also resides at the application boundary?
在大多数项目中,这种重新定位是不必要的。虽然记录器和消息总线是非托管依赖项,因此两者都需要保持向后兼容性,但兼容性的准确性不必相同。对于消息总线,重要的是不允许对消息的结构进行任何更改,因为您永远不知道外部系统将如何对此类更改做出反应。但文本日志的确切结构对于目标受众(支持人员和系统管理员)来说并不那么重要。重要的是这些日志的存在及其携带的信息。因此,IDomainLogger仅模拟就提供了必要的保护级别。
In most projects, such retargeting isn’t necessary. While the logger and the message bus are unmanaged dependencies and, therefore, both require maintaining backward compatibility, the accuracy of that compatibility doesn’t have to be the same. With the message bus, it’s important not to allow any changes to the structure of the messages, because you never know how external systems will react to such changes. But the exact structure of text logs is not that important for the intended audience (support staff and system administrators). What’s important is the existence of those logs and the information they carry. Thus, mocking IDomainLogger alone provides the necessary level of protection.
到目前为止,您已经学习了两种主要的模拟最佳实践:
You’ve learned two major mocking best practices so far:
在本节中,我将解释其余的最佳实践:
In this section, I explain the remaining best practices:
指导原则指出,模拟仅用于集成测试,您不应在单元测试中使用模拟,这源于第 7 章中描述的基本原则:业务逻辑与业务流程分离。您的代码应该与进程外依赖项进行通信,或者很复杂,但绝不能同时兼具两者。这一原则自然导致形成两个不同的层:域模型(处理复杂性)和控制器(处理通信)。
The guideline saying that mocks are for integration tests only, and that you shouldn’t use mocks in unit tests, stems from the foundational principle described in chapter 7: the separation of business logic and orchestration. Your code should either communicate with out-of-process dependencies or be complex, but never both. This principle naturally leads to the formation of two distinct layers: the domain model (that handles complexity) and controllers (that handle the communication).
域模型上的测试属于单元测试类别;涵盖控制器的测试属于集成测试。由于模拟仅用于非托管依赖项,并且控制器是唯一与此类依赖项一起工作的代码,因此您只应在测试控制器时(在集成测试中)应用模拟。
Tests on the domain model fall into the category of unit tests; tests covering controllers are integration tests. Because mocks are for unmanaged dependencies only, and because controllers are the only code working with such dependencies, you should only apply mocking when testing controllers—in integration tests.
您可能有时会听到这样的指导原则:每个测试只进行一次模拟。根据此指导原则,如果您有多个模拟,则可能会同时测试多项内容。
You might sometimes hear the guideline of having only one mock per test. According to this guideline, if you have more than one mock, you are likely testing several things at a time.
这是一个误解,它源于第 2 章中介绍的一个更基本的误解:单元测试中的单元是指代码单元,并且所有此类单元都必须彼此独立地进行测试。恰恰相反:术语“单元”是指行为单元,而不是代码单元。实现此类行为单元所需的代码量无关紧要。它可以跨越多个类、单个类,或者只占用一个很小的方法。
This is a misconception that follows from a more foundational misunderstanding covered in chapter 2: that a unit in a unit test refers to a unit of code, and all such units must be tested in isolation from each other. On the contrary: the term unit means a unit of behavior, not a unit of code. The amount of code it takes to implement such a unit of behavior is irrelevant. It could span across multiple classes, a single class, or take up just a tiny method.
对于模拟,同样的原则也适用:验证一个行为单元需要多少个模拟并不重要。在本章前面,我们用了两个模拟来检查将用户电子邮件从公司更改为非公司的场景:一个用于记录器,另一个用于消息总线。这个数字本来可以更大。事实上,您无法控制在集成测试中使用多少个模拟。模拟的数量完全取决于参与操作的非托管依赖项的数量。
With mocks, the same principle is at play: it’s irrelevant how many mocks it takes to verify a unit of behavior. Earlier in this chapter, it took us two mocks to check the scenario of changing the user email from corporate to non-corporate: one for the logger and the other for the message bus. That number could have been larger. In fact, you don’t have control over how many mocks to use in an integration test. The number of mocks depends solely on the number of unmanaged dependencies participating in the operation.
当涉及与非托管依赖项的通信时,务必确保以下两点:
When it comes to communications with unmanaged dependencies, it’s important to ensure both of the following:
这一要求再次源于保持与非托管依赖项的向后兼容性的需求。兼容性必须是双向的:您的应用程序不应忽略外部系统期望的消息,也不应产生意外消息。仅仅检查被测系统是否发送了如下消息是不够的:
This requirement, once again, stems from the need to maintain backward compatibility with unmanaged dependencies. The compatibility must go both ways: your application shouldn’t omit messages that external systems expect, and it also shouldn’t produce unexpected messages. It’s not enough to check that the system under test sends a message like this:
messageBusMock.验证(
x => x.SendEmailChangedMessage(用户.UserId, “new@gmail.com”));messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"));
您还需要确保该消息只发送一次:
You also need to ensure that this message is sent exactly once:
messageBusMock.验证(
x => x.SendEmailChangedMessage(用户.UserId, “new@gmail.com”),
次.一次); 1messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
Times.Once); 1
对于大多数模拟库,您还可以显式验证模拟中没有其他调用。在 Moq(我选择的模拟库)中,此验证如下所示:
With most mocking libraries, you can also explicitly verify that no other calls are made on the mock. In Moq (the mocking library of my choice), this verification looks as follows:
messageBusMock.验证(
x => x.SendEmailChangedMessage(用户.UserId, “new@gmail.com”),
次.一次);
messageBusMock.VerifyNoOtherCalls(); 1messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
Times.Once);
messageBusMock.VerifyNoOtherCalls(); 1
BusSpy也实现了这个功能:
BusSpy implements this functionality, too:
巴士间谍
.应发送消息数(1)
.WithEmailChangedMessage(用户.UserId,“new@gmail.com”);busSpy
.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");
间谍的检查ShouldSendNumberOfMessages(1)包括模拟的验证Times.Once和实际的验证。VerifyNoOtherCalls()
The spy’s check ShouldSendNumberOfMessages(1) encompasses both Times.Once and VerifyNoOtherCalls() verifications from the mock.
我要谈的最后一条准则是仅模拟您拥有的类型。它是由 Steve Freeman 和 Nat Pryce 首次提出的。[ 1 ]该准则指出,您应该始终在第三方库之上编写自己的适配器,并模拟这些适配器而不是底层类型。他们的一些论点如下:
The last guideline I’d like to talk about is mocking only types that you own. It was first introduced by Steve Freeman and Nat Pryce.[1] The guideline states that you should always write your own adapters on top of third-party libraries and mock those adapters instead of the underlying types. A few of their arguments are as follows:
请参阅 Steve Freeman 和 Nat Pryce 所著的《成长中的面向对象软件:通过测试指导》(Addison-Wesley Professional,2009 年) 第 69 页。
See page 69 in Growing Object-Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce (Addison-Wesley Professional, 2009).
我完全同意这个分析。适配器实际上充当了代码和外部世界之间的防腐层。[ 2 ]这些可以帮助您
I fully agree with this analysis. Adapters, in effect, act as an anti-corruption layer between your code and the external world.[2] These help you to
请参阅Eric Evans 所著的《领域驱动设计:解决软件核心的复杂性》(Addison-Wesley,2003 年)。
See Domain-Driven Design: Tackling Complexity in the Heart of Software by Eric Evans (Addison-Wesley, 2003).
我们示例 CRM 项目中的接口IBus正是用于此目的。即使底层消息总线的库提供了与 一样漂亮和干净的接口IBus,您仍然最好在其上引入自己的包装器。您永远不知道升级库时第三方代码将如何变化。这样的升级可能会对整个代码库产生连锁反应!额外的抽象层将连锁反应限制在一个类上:适配器本身。
The IBus interface in our sample CRM project serves exactly that purpose. Even if the underlying message bus’s library provides as nice and clean an interface as IBus, you are still better off introducing your own wrapper on top of it. You never know how the third-party code will change when you upgrade the library. Such an upgrade could cause a ripple effect across the whole code base! The additional abstraction layer restricts that ripple effect to just one class: the adapter itself.
请注意,“模拟您自己的类型”准则不适用于进程内依赖项。如前所述,模拟仅适用于非托管依赖项。因此,无需抽象内存中或托管依赖项。例如,如果库提供了日期和时间 API,您可以按原样使用该 API,因为它不会涉及非托管依赖项。同样,只要 ORM 用于访问外部应用程序不可见的数据库,就无需抽象它。当然,您可以在任何库之上引入自己的包装器,但对于非托管依赖项以外的任何其他东西,这很少值得付出努力。
Note that the “mock your own types” guideline doesn’t apply to in-process dependencies. As I explained previously, mocks are for unmanaged dependencies only. Thus, there’s no need to abstract in-memory or managed dependencies. For instance, if a library provides a date and time API, you can use that API as-is, because it doesn’t reach out to unmanaged dependencies. Similarly, there’s no need to abstract an ORM as long as it’s used for accessing a database that isn’t visible to external applications. Of course, you can introduce your own wrapper on top of any library, but it’s rarely worth the effort for anything other than unmanaged dependencies.
集成测试的最后一块拼图是管理进程外依赖项。管理依赖项最常见的示例是应用程序数据库 — 其他应用程序无权访问的数据库。
The last piece of the puzzle in integration testing is managed out-of-process dependencies. The most common example of a managed dependency is an application database—a database no other application has access to.
针对真实数据库运行测试可以防止回归,但这些测试并不容易设置。本章介绍了开始测试数据库之前需要采取的初步步骤:它涵盖了如何跟踪数据库架构,解释了基于状态和基于迁移的数据库交付方法之间的区别,并说明了为什么应该选择后者而不是前者。
Running tests against a real database provides bulletproof protection against regressions, but those tests aren’t easy to set up. This chapter shows the preliminary steps you need to take before you can start testing your database: it covers keeping track of the database schema, explains the difference between the state-based and migration-based database delivery approaches, and demonstrates why you should choose the latter over the former.
学习基础知识后,您将了解如何在测试期间管理事务、清理剩余数据,并通过消除无关紧要的部分并放大要点来保持测试规模较小。本章重点介绍关系数据库,但许多相同的原则也适用于其他类型的数据存储,例如面向文档的数据库甚至纯文本文件存储。
After learning the basics, you’ll see how to manage transactions during the test, clean up leftover data, and keep tests small by eliminating insignificant parts and amplifying the essentials. This chapter focuses on relational databases, but many of the same principles are applicable to other types of data stores such as document-oriented databases or even plain text file storages.
您可能还记得第 8 章的内容,托管依赖项应按原样包含在集成测试中。这使得处理这些依赖项比处理非托管依赖项更费力,因为使用模拟是不可能的。但即使在开始编写测试之前,您也必须采取准备步骤来启用集成测试。在本节中,您将看到以下先决条件:
As you might recall from chapter 8, managed dependencies should be included as-is in integration tests. That makes working with those dependencies more laborious than unmanaged ones because using a mock is out of the question. But even before you start writing tests, you must take preparatory steps to enable integration testing. In this section, you’ll see these prerequisites:
不过,就像测试中的几乎所有事情一样,促进测试的做法也会改善数据库的整体健康状况。即使您不编写集成测试,您也会从这些做法中获益。
Like almost everything in testing, though, practices that facilitate testing also improve the health of your database in general. You’ll get value out of those practices even if you don’t write integration tests.
测试数据库的第一步是将数据库架构视为常规代码。与常规代码一样,数据库架构最好存储在 Git 等源代码控制系统中。
The first step on the way to testing the database is treating the database schema as regular code. Just as with regular code, a database schema is best stored in a source control system such as Git.
我曾经参与过一些项目,其中程序员维护一个专用的数据库实例,作为参考点(模型数据库)。在开发过程中,所有架构更改都累积在该实例中。在生产部署时,团队比较了生产数据库和模型数据库,使用特殊工具生成升级脚本,并在生产中运行这些脚本(图 10.1)。
I’ve worked on projects where programmers maintained a dedicated database instance, which served as a reference point (a model database). During development, all schema changes accumulated in that instance. Upon production deployments, the team compared the production and model databases, used a special tool to generate upgrade scripts, and ran those scripts in production (figure 10.1).
Using a model database is a horrible way to maintain database schema. That’s because there’s
另一方面,将所有数据库架构更新保存在源代码控制系统中有助于您维护单一事实来源,并跟踪数据库更改以及常规代码的更改。不应在源代码控制之外对数据库结构进行任何修改。
On the other hand, keeping all the database schema updates in the source control system helps you to maintain a single source of truth and also to track database changes along with the changes of regular code. No modifications to the database structure should be made outside of the source control.
说到数据库架构,通常想到的是表、视图、索引、存储过程以及构成数据库构造蓝图的任何其他内容。架构本身以 SQL 脚本的形式表示。您应该能够在开发过程中的任何时候使用这些脚本来创建您自己的功能齐全、最新的数据库实例。然而,数据库的另一部分属于数据库架构,但很少被视为这样:参考数据。
When it comes to the database schema, the usual suspects are tables, views, indexes, stored procedures, and anything else that forms a blueprint of how the database is constructed. The schema itself is represented in the form of SQL scripts. You should be able to use those scripts to create a fully functional, up-to-date database instance of your own at any time during development. However, there’s another part of the database that belongs to the database schema but is rarely viewed as such: reference data.
参考数据是为了应用程序正常运行而必须预先填充的数据。
Reference data is data that must be prepopulated in order for the application to operate properly.
以前面章节中的 CRM 系统为例。其用户可以是类型Customer或类型Employee。假设您要创建一个包含所有用户类型的表,并向User该表引入外键约束。这样的约束将提供额外的保证,即应用程序永远不会为用户分配不存在的类型。在这种情况下,UserType表的内容将是参考数据,因为应用程序依赖它的存在来将用户保留在数据库中。
Take the CRM system from the earlier chapters, for example. Its users can be either of type Customer or type Employee. Let’s say that you want to create a table with all user types and introduce a foreign key constraint from User to that table. Such a constraint would provide an additional guarantee that the application won’t ever assign a user a nonexistent type. In this scenario, the content of the UserType table would be reference data because the application relies on its existence in order to persist users in the database.
有一个简单的方法可以区分参考数据和常规数据。如果您的应用程序可以修改数据,则它是常规数据;如果不能,则它是参考数据。
There’s a simple way to differentiate reference data from regular data. If your application can modify the data, it’s regular data; if not, it’s reference data.
由于参考数据对于您的应用程序至关重要,因此您应该将其与表、视图和数据库模式的其他部分一起以 SQL 语句的形式保存在源代码控制系统中INSERT。
Because reference data is essential for your application, you should keep it in the source control system along with tables, views, and other parts of the database schema, in the form of SQL INSERT statements.
请注意,尽管引用数据通常与常规数据分开存储,但两者有时可以共存于同一张表中。要实现这一点,您需要引入一个标志,区分可修改的数据(常规数据)和不可修改的数据(引用数据),并禁止您的应用程序更改后者。
Note that although reference data is normally stored separately from regular data, the two can sometimes coexist in the same table. To make this work, you need to introduce a flag differentiating data that can be modified (regular data) from data that can’t be modified (reference data) and forbid your application from changing the latter.
针对真实数据库运行测试已经够难的了。如果你必须与其他开发人员共享该数据库,那就更加困难了。使用共享数据库会阻碍开发过程,因为
It’s difficult enough to run tests against a real database. It becomes even more difficult if you have to share that database with other developers. The use of a shared database hinders the development process because
为每个开发人员保留一个单独的数据库实例,最好在该开发人员自己的机器上,以最大限度地提高测试执行速度。
Keep a separate database instance for every developer, preferably on that developer’s own machine in order to maximize test execution speed.
数据库传输主要有两种方法:基于状态和基于迁移。基于迁移的方法最初更难实现和维护,但从长远来看,它比基于状态的方法效果要好得多。
There are two major approaches to database delivery: state-based and migration-based. The migration-based approach is more difficult to implement and maintain initially, but it works much better than the state-based approach in the long run.
基于状态的数据库交付方法与我在图 10.1中描述的方法类似。您还有一个在整个开发过程中维护的模型数据库。在部署期间,比较工具会为生产数据库生成脚本,使其与模型数据库保持同步。不同之处在于,使用基于状态的方法,您实际上并没有物理模型数据库作为事实来源。相反,您有可用于创建该数据库的 SQL 脚本。脚本存储在源代码控制中。
The state-based approach to database delivery is similar to what I described in figure 10.1. You also have a model database that you maintain throughout development. During deployments, a comparison tool generates scripts for the production database to bring it up to date with the model database. The difference is that with the state-based approach, you don’t actually have a physical model database as a source of truth. Instead, you have SQL scripts that you can use to create that database. The scripts are stored in the source control.
在基于状态的方法中,比较工具会完成所有繁重的工作。无论生产数据库的状态如何,该工具都会执行使其与模型数据库同步所需的一切:删除不必要的表、创建新表、重命名列等等。
In the state-based approach, the comparison tool does all the hard lifting. Whatever the state of the production database, the tool does everything needed to get it in sync with the model database: delete unnecessary tables, create new ones, rename columns, and so on.
另一方面,基于迁移的方法强调使用显式迁移将数据库从一个版本转换到另一个版本(图 10.2)。使用这种方法,您无需使用工具自动同步生产和开发数据库;您可以自己编写升级脚本。但是,在检测生产数据库架构中未记录的更改时,数据库比较工具仍然很有用。
On the other hand, the migration-based approach emphasizes the use of explicit migrations that transition the database from one version to another (figure 10.2). With this approach, you don’t use tools to automatically synchronize the production and development databases; you come up with upgrade scripts yourself. However, a database comparison tool can still be useful when detecting undocumented changes in the production database schema.
在基于迁移的方法中,迁移(而不是数据库状态)将成为您存储在源代码控制中的工件。迁移通常用纯 SQL 脚本表示(流行的工具包括 Flyway [ https://flywaydb.org ] 和 Liquibase [ https://liquibase.org ]),但也可以使用可翻译成 SQL 的类似 DSL 的语言编写。以下示例展示了一个 C# 类,该类借助 FluentMigrator 库(https://github.com/fluentmigrator/fluentmigrator)表示数据库迁移:
In the migration-based approach, migrations and not the database state become the artifacts you store in the source control. Migrations are usually represented with plain SQL scripts (popular tools include Flyway [https://flywaydb.org] and Liquibase [https://liquibase.org]), but they can also be written using a DSL-like language that gets translated into SQL. The following example shows a C# class that represents a database migration with the help of the FluentMigrator library (https://github.com/fluentmigrator/fluentmigrator):
[迁移(1)] 1
公共类 CreateUserTable:迁移
{
公共覆盖无效向上() 2
{
创建.表(“用户”);
}
公共覆盖无效向下() 3
{
删除.表(“用户”);
}
}[Migration(1)] 1
public class CreateUserTable : Migration
{
public override void Up() 2
{
Create.Table("Users");
}
public override void Down() 3
{
Delete.Table("Users");
}
}
基于状态和基于迁移的数据库交付方法之间的区别归结为(正如其名称所暗示的)状态与迁移(见图10.3):
The difference between the state-based and migration-based approaches to database delivery comes down to (as their names imply) state versus migrations (see figure 10.3):
这种区别导致了不同的权衡。数据库状态的明确性使得处理合并冲突更加容易,而显式迁移则有助于解决数据移动问题。
Such a distinction leads to different sets of trade-offs. The explicitness of the database state makes it easier to handle merge conflicts, while explicit migrations help to tackle data motion.
数据移动是改变现有数据的形状以使其符合新的数据库模式的过程。
Data motion is the process of changing the shape of existing data so that it conforms to the new database schema.
尽管缓解合并冲突和简化数据移动看起来是同等重要的好处,但在绝大多数项目中,数据移动比合并冲突重要得多。除非您尚未将应用程序发布到生产环境,否则您总是有一些不能简单丢弃的数据。
Although the alleviation of merge conflicts and the ease of data motion might look like equally important benefits, in the vast majority of projects, data motion is much more important than merge conflicts. Unless you haven’t yet released your application to production, you always have data that you can’t simply discard.
例如,将一Name列拆分为FirstName和 时LastName,您不仅必须删除该Name列并创建新的FirstName和LastName列,还必须编写脚本将所有现有名称拆分为两部分。使用状态驱动方法没有简单的方法来实现此更改;比较工具在管理数据方面很糟糕。原因是虽然数据库模式本身是客观的,这意味着只有一种方法可以解释它,但数据是依赖于上下文的。在生成升级脚本时,没有工具可以对数据做出可靠的假设。您必须应用特定于域的规则才能实现正确的转换。
For example, when splitting a Name column into FirstName and LastName, you not only have to drop the Name column and create the new FirstName and LastName columns, but you also have to write a script to split all existing names into two pieces. There is no easy way to implement this change using the state-driven approach; comparison tools are awful when it comes to managing data. The reason is that while the database schema itself is objective, meaning there is only one way to interpret it, data is context-dependent. No tool can make reliable assumptions about data when generating upgrade scripts. You have to apply domain-specific rules in order to implement proper transformations.
因此,基于状态的方法在绝大多数项目中都不切实际。不过,在项目尚未发布到生产环境时,您可以暂时使用它。毕竟,测试数据并不那么重要,每次更改数据库时都可以重新创建它。但是,一旦您发布了第一个版本,您就必须切换到基于迁移的方法,以便正确处理数据移动。
As a result, the state-based approach is impractical in the vast majority of projects. You can use it temporarily, though, while the project still has not been released to production. After all, test data isn’t that important, and you can re-create it every time you change the database. But once you release the first version, you will have to switch to the migration-based approach in order to handle data motion properly.
通过迁移将每个修改应用到数据库架构(包括参考数据)。一旦迁移提交到源代码控制,就不要修改它们。如果迁移不正确,请创建新的迁移,而不是修复旧迁移。只有当不正确的迁移可能导致数据丢失时,才可以例外处理此规则。
Apply every modification to the database schema (including reference data) through migrations. Don’t modify migrations once they are committed to the source control. If a migration is incorrect, create a new migration instead of fixing the old one. Make exceptions to this rule only when the incorrect migration can lead to data loss.
数据库事务管理是生产和测试代码中都很重要的一个主题。生产代码中的正确事务管理有助于避免数据不一致。在测试中,它可以帮助您在接近生产环境中验证与数据库的集成。
Database transaction management is a topic that’s important for both production and test code. Proper transaction management in production code helps you avoid data inconsistencies. In tests, it helps you verify integration with the database in a close-to-production setting.
在本节中,我将首先展示如何在生产代码(控制器)中处理事务,然后演示如何在集成测试中使用它们。我将继续使用您在前面章节中看到的相同 CRM 项目作为示例。
In this section, I’ll first show how to handle transactions in the production code (the controller) and then demonstrate how to use them in integration tests. I’ll continue using the same CRM project you saw in the earlier chapters as an example.
我们的示例 CRM 项目使用该类Database来处理User和Company。Database在每个方法调用上创建一个单独的 SQL 连接。每个这样的连接都会在后台隐式打开一个独立的事务,如下面的清单所示。
Our sample CRM project uses the Database class to work with User and Company. Database creates a separate SQL connection on each method call. Every such connection implicitly opens an independent transaction behind the scenes, as the following listing shows.
公共类数据库
{
私有只读字符串_connectionString;
公共数据库(字符串连接字符串)
{
_connectionString = 连接字符串;
}
public void SaveUser(User user)
{
bool isNewUser = 用户.UserId == 0;
使用(var 连接=
新的 SqlConnection(_connectionString)) 1
{
/* 根据 isNewUser 插入或更新用户 */
}
}
公共无效SaveCompany(公司公司)
{
使用(var 连接=
新的 SqlConnection(_connectionString)) 1
{
/* 仅更新;只有一家公司 */
}
}
}public class Database
{
private readonly string _connectionString;
public Database(string connectionString)
{
_connectionString = connectionString;
}
public void SaveUser(User user)
{
bool isNewUser = user.UserId == 0;
using (var connection =
new SqlConnection(_connectionString)) 1
{
/* Insert or update the user depending on isNewUser */
}
}
public void SaveCompany(Company company)
{
using (var connection =
new SqlConnection(_connectionString)) 1
{
/* Update only; there's only one company */
}
}
}
因此,用户控制器在单个业务操作期间总共创建四个数据库事务,如下面的清单所示。
As a result, the user controller creates a total of four database transactions during a single business operation, as shown in the following listing.
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
对象[] 用户数据 = _database.GetUserById(用户Id); 1
用户用户 = UserFactory.创建(用户数据);
字符串错误 = 用户.CanChangeEmail();
如果(错误!=空)
返回错误;
对象[] companyData = _database.GetCompany(); 1
公司公司 = CompanyFactory.Create(companyData); 1
用户.更改电子邮件(新电子邮件,公司);
_database.SaveCompany(公司); 1
_database.SaveUser(用户); 1
_eventDispatcher.Dispatch(用户.域事件);
返回“OK”;
}public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId); 1
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany(); 1
Company company = CompanyFactory.Create(companyData); 1
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company); 1
_database.SaveUser(user); 1
_eventDispatcher.Dispatch(user.DomainEvents);
return "OK";
}
在只读操作期间打开多个事务是可以的:例如,在将用户信息返回到外部客户端时。但如果业务操作涉及数据变更,则在该操作期间发生的所有更新都应是原子的,以避免不一致。例如,控制器可以成功保留公司,但由于数据库连接问题,在保存用户时会失败。因此,公司的数量可能与数据库中的用户NumberOfEmployees总数不一致。Employee
It’s fine to open multiple transactions during read-only operations: for example, when returning user information to the external client. But if the business operation involves data mutation, all updates taking place during that operation should be atomic in order to avoid inconsistencies. For example, the controller can successfully persist the company but then fail when saving the user due to a database connectivity issue. As a result, the company’s NumberOfEmployees can become inconsistent with the total number of Employee users in the database.
原子更新以全有或全无的方式执行。原子更新集中的每个更新都必须完全完成,否则将没有任何效果。
Atomic updates are executed in an all-or-nothing manner. Each update in the set of atomic updates must either be complete in its entirety or have no effect whatsoever.
为了避免潜在的不一致,您需要将两种类型的决策分开:
To avoid potential inconsistencies, you need to introduce a separation between two types of decisions:
这种分离很重要,因为控制器无法同时做出这些决定。它只知道当业务操作中的所有步骤都成功时是否可以保留更新。并且它只能通过访问数据库并尝试进行更新来执行这些步骤。您可以通过将类拆分Database为存储库和事务来实现这些职责之间的分离:
Such a separation is important because the controller can’t make these decisions simultaneously. It only knows whether the updates can be kept when all the steps in the business operation have succeeded. And it can only take those steps by accessing the database and trying to make the updates. You can implement the separation between these responsibilities by splitting the Database class into repositories and a transaction:
存储库和事务不仅职责不同,而且生命周期也不同。事务存在于整个业务操作期间,并在业务操作结束时被处理。而存储库则存在时间较短。您可以在调用数据库后立即处理存储库。因此,存储库始终在当前事务之上工作。连接到数据库时,存储库会将自身加入事务,以便在该连接期间进行的任何数据修改都可以由事务回滚。
Not only do repositories and transactions have different responsibilities, but they also have different lifespans. A transaction lives during the whole business operation and is disposed of at the very end of it. A repository, on the other hand, is short-lived. You can dispose of a repository as soon as the call to the database is completed. As a result, repositories always work on top of the current transaction. When connecting to the database, a repository enlists itself into the transaction so that any data modifications made during that connection can later be rolled back by the transaction.
图 10.4展示了清单 10.2中控制器和数据库之间的通信方式。每个数据库调用都被包装到自己的事务中;更新不是原子的。
Figure 10.4 shows how the communication between the controller and the database looks in listing 10.2. Each database call is wrapped into its own transaction; updates are not atomic.
图 10.5显示了引入显式事务后的应用程序。事务调解控制器和数据库之间的交互。所有四个数据库调用仍然存在,但现在数据修改要么提交,要么完全回滚。
Figure 10.5 shows the application after the introduction of explicit transactions. The transaction mediates interactions between the controller and the database. All four database calls are still there, but now data modifications are either committed or rolled back in full.
以下清单展示了引入事务和存储库后的控制器。
The following listing shows the controller after introducing a transaction and repositories.
公共类用户控制器
{
私人只读事务_transaction;
私有只读用户存储库 _userRepository;
私人只读公司存储库 _companyRepository;
私有只读EventDispatcher _eventDispatcher;
公共用户控制器(
交易交易, 1
消息总线 消息总线,
IDomainLogger 域日志记录器)
{
_transaction = 交易;
_userRepository = 新 UserRepository(事务);
_companyRepository = 新 CompanyRepository (交易);
_eventDispatcher = 新的 EventDispatcher(
消息总线,域记录器);
}
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
对象[] 用户数据 = _userRepository 2
.GetUserById(用户Id); 2
用户用户 = UserFactory.创建(用户数据);
字符串错误 = 用户.CanChangeEmail();
如果(错误!=空)
返回错误;
对象[] companyData = _companyRepository 2
.GetCompany(); 2
公司公司 = CompanyFactory.Create(companyData);
用户.更改电子邮件(新电子邮件,公司);
_companyRepository.SaveCompany(公司); 2
_userRepository.SaveUser(用户); 2
_eventDispatcher.Dispatch(用户.域事件);
_事务.提交(); 3
返回“OK”;
}
}
公共类用户存储库
{
私人只读事务_transaction;
公共用户存储库(交易事务) 4
{
_transaction = 交易;
}
/* ... */
}
公共类事务:IDisposable
{
公共无效提交(){/*...*/}
公共无效处置(){/*...*/}
}public class UserController
{
private readonly Transaction _transaction;
private readonly UserRepository _userRepository;
private readonly CompanyRepository _companyRepository;
private readonly EventDispatcher _eventDispatcher;
public UserController(
Transaction transaction, 1
MessageBus messageBus,
IDomainLogger domainLogger)
{
_transaction = transaction;
_userRepository = new UserRepository(transaction);
_companyRepository = new CompanyRepository(transaction);
_eventDispatcher = new EventDispatcher(
messageBus, domainLogger);
}
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _userRepository 2
.GetUserById(userId); 2
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _companyRepository 2
.GetCompany(); 2
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_companyRepository.SaveCompany(company); 2
_userRepository.SaveUser(user); 2
_eventDispatcher.Dispatch(user.DomainEvents);
_transaction.Commit(); 3
return "OK";
}
}
public class UserRepository
{
private readonly Transaction _transaction;
public UserRepository(Transaction transaction) 4
{
_transaction = transaction;
}
/* ... */
}
public class Transaction : IDisposable
{
public void Commit() { /* ... */ }
public void Dispose() { /* ... */ }
}
该类的内部结构Transaction并不重要,但如果您好奇的话,我TransactionScope在后台使用了 .NET 的标准。重要的是Transaction它包含两种方法:
The internals of the Transaction class aren’t important, but if you’re curious, I’m using .NET’s standard TransactionScope behind the scenes. The important part about Transaction is that it contains two methods:
Commit()和的这种组合Dispose()保证了数据库仅在顺利路径(业务场景成功执行)期间被更改。这就是为什么Commit()位于方法的最末端ChangeEmail()。如果发生任何错误,无论是验证错误还是未处理的异常,执行流程都会提前返回,从而阻止事务提交。
Such a combination of Commit() and Dispose() guarantees that the database is altered only during happy paths (the successful execution of the business scenario). That’s why Commit() resides at the very end of the ChangeEmail() method. In the event of any error, be it a validation error or an unhandled exception, the execution flow returns early and thereby prevents the transaction from being committed.
Commit()由控制器调用,因为此方法调用需要决策。Dispose()但是,调用时不涉及决策,因此您可以将该方法调用委托给基础架构层的类。实例化控制器并为其提供必要依赖项的同一类也应在控制器完成工作后处理事务。
Commit() is invoked by the controller because this method call requires decision-making. There’s no decision-making involved in calling Dispose(), though, so you can delegate that method call to a class from the infrastructure layer. The same class that instantiates the controller and provides it with the necessary dependencies should also dispose of the transaction once the controller is done working.
注意如何UserRepository将requiresTransaction作为构造函数参数。这明确表明存储库始终在事务之上工作;存储库不能单独调用数据库。
Notice how UserRepository requires Transaction as a constructor parameter. This explicitly shows that repositories always work on top of transactions; a repository can’t call the database on its own.
引入存储库和事务是避免潜在数据不一致的好方法,但还有更好的方法。您可以将Transaction类升级为工作单元。
The introduction of repositories and a transaction is a good way to avoid potential data inconsistencies, but there’s an even better approach. You can upgrade the Transaction class to a unit of work.
工作单元维护受业务操作影响的对象列表。操作完成后,工作单元会找出需要更改数据库的所有更新,并作为一个单元执行这些更新(因此得名)。
A unit of work maintains a list of objects affected by a business operation. Once the operation is completed, the unit of work figures out all updates that need to be done to alter the database and executes those updates as a single unit (hence the pattern name).
工作单元相对于普通事务的主要优势是延迟更新。与事务不同,工作单元在业务操作结束时执行所有更新,从而最大限度地缩短底层数据库事务的持续时间并减少数据拥塞(见图10.6)。通常,此模式还有助于减少数据库调用的次数。
The main advantage of a unit of work over a plain transaction is the deferral of updates. Unlike a transaction, a unit of work executes all updates at the end of the business operation, thus minimizing the duration of the underlying database transaction and reducing data congestion (see figure 10.6). Often, this pattern also helps to reduce the number of database calls.
数据库事务也实现工作单元模式。
Database transactions also implement the unit-of-work pattern.
维护已修改对象的列表,然后确定要生成哪些 SQL 脚本,这看起来是一项繁重的工作。但实际上,您不需要自己做这项工作。大多数对象关系映射 (ORM) 库都会为您实现工作单元模式。例如,在 .NET 中,您可以使用 NHibernate 或 Entity Framework,它们都提供了完成所有繁重工作的类(这些类分别是ISession和DbContext)。以下清单显示了UserController与 Entity Framework 结合使用时的情况。
Maintaining a list of modified objects and then figuring out what SQL script to generate can look like a lot of work. In reality, though, you don’t need to do that work yourself. Most object-relational mapping (ORM) libraries implement the unit-of-work pattern for you. In .NET, for example, you can use NHibernate or Entity Framework, both of which provide classes that do all the hard lifting (those classes are ISession and DbContext, respectively). The following listing shows how UserController looks in combination with Entity Framework.
公共类用户控制器
{
私有只读CrmContext _context;
私有只读用户存储库 _userRepository;
私人只读公司存储库 _companyRepository;
私有只读EventDispatcher _eventDispatcher;
公共用户控制器(
CrmContext 上下文, 1
消息总线 消息总线,
IDomainLogger 域日志记录器)
{
_context = 上下文;
_userRepository = 新用户存储库(
上下文); 1
_companyRepository = 新 CompanyRepository(
上下文); 1
_eventDispatcher = 新的 EventDispatcher(
消息总线,域记录器);
}
公共字符串 ChangeEmail(int userId,字符串 newEmail)
{
用户用户 = _userRepository.GetUserById(用户Id);
字符串错误 = 用户.CanChangeEmail();
如果(错误!=空)
返回错误;
公司 company = _companyRepository.GetCompany();
用户.更改电子邮件(新电子邮件,公司);
_companyRepository.保存公司(公司);
_userRepository.保存用户(用户);
_eventDispatcher.Dispatch(用户.域事件);
_context.保存更改(); 1
返回“OK”;
}
}public class UserController
{
private readonly CrmContext _context;
private readonly UserRepository _userRepository;
private readonly CompanyRepository _companyRepository;
private readonly EventDispatcher _eventDispatcher;
public UserController(
CrmContext context, 1
MessageBus messageBus,
IDomainLogger domainLogger)
{
_context = context;
_userRepository = new UserRepository(
context); 1
_companyRepository = new CompanyRepository(
context); 1
_eventDispatcher = new EventDispatcher(
messageBus, domainLogger);
}
public string ChangeEmail(int userId, string newEmail)
{
User user = _userRepository.GetUserById(userId);
string error = user.CanChangeEmail();
if (error != null)
return error;
Company company = _companyRepository.GetCompany();
user.ChangeEmail(newEmail, company);
_companyRepository.SaveCompany(company);
_userRepository.SaveUser(user);
_eventDispatcher.Dispatch(user.DomainEvents);
_context.SaveChanges(); 1
return "OK";
}
}
CrmContext是一个自定义类,包含域模型和数据库之间的映射(它继承自 Entity Framework 的)。清单 10.4DbContext中的控制器使用而不是。因此,CrmContextTransaction
CrmContext is a custom class that contains mapping between the domain model and the database (it inherits from Entity Framework’s DbContext). The controller in listing 10.4 uses CrmContext instead of Transaction. As a result,
请注意,不再需要UserFactoryandCompanyFactory因为实体框架现在充当原始数据库数据和域对象之间的映射器。
Notice that there’s no need for UserFactory and CompanyFactory anymore because Entity Framework now serves as a mapper between the raw database data and domain objects.
使用关系数据库时,很容易避免数据不一致:所有主要关系数据库都提供原子更新,可根据需要跨越任意多行。但如何使用非关系数据库(如 MongoDB)实现相同级别的保护?
It’s easy to avoid data inconsistencies when using a relational database: all major relational databases provide atomic updates that can span as many rows as needed. But how do you achieve the same level of protection with a non-relational database such as MongoDB?
大多数非关系型数据库的问题在于缺乏传统意义上的事务;原子更新只能在单个文档内得到保证。如果业务操作影响多个文档,则容易出现不一致。(在非关系型数据库中,文档相当于一行。)
The problem with most non-relational databases is the lack of transactions in the classical sense; atomic updates are guaranteed only within a single document. If a business operation affects multiple documents, it becomes prone to inconsistencies. (In non-relational databases, a document is the equivalent of a row.)
非关系型数据库从不同的角度处理不一致问题:它们要求您设计文档,以便任何业务操作都不会同时修改多个文档。这是可能的,因为文档比关系型数据库中的行更灵活。单个文档可以存储任何形状和复杂程度的数据,从而捕获最复杂的业务操作的副作用。
Non-relational databases approach inconsistencies from a different angle: they require you to design your documents such that no business operation modifies more than one of those documents at a time. This is possible because documents are more flexible than rows in relational databases. A single document can store data of any shape and complexity and thus capture side effects of even the most sophisticated business operations.
在领域驱动设计中,有一条准则规定,您不应在每个业务操作中修改多个聚合。该准则服务于同一个目标:保护您免受数据不一致的影响。不过,该准则仅适用于使用文档数据库的系统,其中每个文档对应一个聚合。
In domain-driven design, there’s a guideline saying that you shouldn’t modify more than one aggregate per business operation. This guideline serves the same goal: protecting you from data inconsistencies. The guideline is only applicable to systems that work with document databases, though, where each document corresponds to one aggregate.
在集成测试中管理数据库事务时,请遵循以下准则:不要在测试的各个部分之间重用数据库事务或工作单元CrmContext。以下清单显示了将测试切换到实体框架后在集成测试中重用的示例。
When it comes to managing database transactions in integration tests, adhere to the following guideline: don’t reuse database transactions or units of work between sections of the test. The following listing shows an example of reusing CrmContext in the integration test after switching that test to Entity Framework.
[事实]
公共无效 Changing_email_from_corporate_to_non_corporate()
{
使用(var context = 1
new CrmContext(ConnectionString)) 1
{
// 安排
var userRepository = 2
new UserRepository(context); 2
var companyRepository = 2
new CompanyRepository(context); 2
var 用户 = 新用户(0,“user@mycorp.com”,
用户类型.员工,false);
用户存储库.保存用户(用户);
var company = new Company("mycorp.com", 1);
companyRepository.保存公司(公司);
上下文.保存更改(); 2
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(
上下文, 3
消息总线,
loggerMock.对象);
// 行为
字符串结果 = sut.ChangeEmail(用户.UserId,“new@gmail.com”);
// 断言
Assert.Equal("OK",结果);
用户 userFromDb = userRepository 4
.GetUserById(user.UserId); 4
断言.Equal("new@gmail.com", userFromDb.Email);
断言.等于(用户类型.客户,用户来自数据库.类型);
公司 companyFromDb = companyRepository 4
.GetCompany(); 4
断言.等于(0,companyFromDb.NumberOfEmployees);
busSpy.应该发送的消息数量(1)
.WithEmailChangedMessage(用户.UserId,“new@gmail.com”);
loggerMock.验证(
x => x.UserType已改变(
用户.用户ID,用户类型.员工,用户类型.客户),
次.一次);
}
}[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
using (var context = 1
new CrmContext(ConnectionString)) 1
{
// Arrange
var userRepository = 2
new UserRepository(context); 2
var companyRepository = 2
new CompanyRepository(context); 2
var user = new User(0, "user@mycorp.com",
UserType.Employee, false);
userRepository.SaveUser(user);
var company = new Company("mycorp.com", 1);
companyRepository.SaveCompany(company);
context.SaveChanges(); 2
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(
context, 3
messageBus,
loggerMock.Object);
// Act
string result = sut.ChangeEmail(user.UserId, "new@gmail.com");
// Assert
Assert.Equal("OK", result);
User userFromDb = userRepository 4
.GetUserById(user.UserId); 4
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);
Company companyFromDb = companyRepository 4
.GetCompany(); 4
Assert.Equal(0, companyFromDb.NumberOfEmployees);
busSpy.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");
loggerMock.Verify(
x => x.UserTypeHasChanged(
user.UserId, UserType.Employee, UserType.Customer),
Times.Once);
}
}
CrmContext此测试在所有三个部分(arrange、act 和 assert)中使用了相同的 实例。这是一个问题,因为这种工作单元的重用会创建一个与控制器在生产中遇到的环境不匹配的环境。在生产中,每个业务操作都有一个独占的 实例CrmContext。该实例是在控制器方法调用之前创建的,并在调用之后立即被处理。
This test uses the same instance of CrmContext in all three sections: arrange, act, and assert. This is a problem because such reuse of the unit of work creates an environment that doesn’t match what the controller experiences in production. In production, each business operation has an exclusive instance of CrmContext. That instance is created right before the controller method invocation and is disposed of immediately after.
为了避免行为不一致的风险,集成测试应尽可能地复制生产环境,这意味着 act 部分不能CrmContext与任何其他部分共享。arrange 和 assert 部分也必须获得自己的实例CrmContext,因为您可能还记得第 8 章的内容,独立于用作输入参数的数据检查数据库的状态非常重要。尽管 assert 部分确实独立于 accordion 部分查询用户和公司,但这些部分仍然共享相同的数据库上下文。该上下文可以(许多 ORM 确实这样做)缓存请求的数据以提高性能。
To avoid the risk of inconsistent behavior, integration tests should replicate the production environment as closely as possible, which means the act section must not share CrmContext with anyone else. The arrange and assert sections must get their own instances of CrmContext too, because, as you might remember from chapter 8, it’s important to check the state of the database independently of the data used as input parameters. And although the assert section does query the user and the company independently of the arrange section, these sections still share the same database context. That context can (and many ORMs do) cache the requested data for performance improvements.
在集成测试中至少使用三个事务或工作单元:每个安排、操作和断言部分一个。
Use at least three transactions or units of work in an integration test: one per each arrange, act, and assert section.
共享数据库带来了集成测试相互隔离的问题。为了解决这个问题,你需要
The shared database raises the problem of isolating integration tests from each other. To solve this problem, you need to
总体而言,您的测试不应该依赖于数据库的状态。您的测试应该自行将该状态带入所需的条件。
Overall, your tests shouldn’t depend on the state of the database. Your tests should bring that state to the required condition on their own.
并行执行集成测试需要付出巨大努力。您必须确保所有测试数据都是唯一的,这样就不会违反数据库约束,测试也不会意外地接连拾取输入数据。清理剩余数据也变得更加棘手。按顺序运行集成测试比花时间尝试从中榨取额外的性能更为实际。
Parallel execution of integration tests involves significant effort. You have to ensure that all test data is unique so no database constraints are violated and tests don’t accidentally pick up input data after each other. Cleaning up leftover data also becomes trickier. It’s more practical to run integration tests sequentially rather than spend time trying to squeeze additional performance out of them.
大多数单元测试框架允许您定义单独的测试集合并有选择地禁用其中的并行化。创建两个这样的集合(用于单元测试和集成测试),然后在集成测试的集合中禁用测试并行化。
Most unit testing frameworks allow you to define separate test collections and selectively disable parallelization in them. Create two such collections (for unit and integration tests), and then disable test parallelization in the collection with the integration tests.
作为替代方案,您可以使用容器并行化测试。例如,您可以将模型数据库放在 Docker 映像上,并为每个集成测试从该映像实例化一个新容器。但在实践中,这种方法会带来太多额外的维护负担。使用 Docker,您不仅必须跟踪数据库本身,还需要
As an alternative, you could parallelize tests using containers. For example, you could put the model database on a Docker image and instantiate a new container from that image for each integration test. In practice, though, this approach creates too much of an additional maintenance burden. With Docker, you not only have to keep track of the database itself, but you also need to
除非你绝对需要最小化集成测试的执行时间,否则我不建议使用容器。同样,每个开发人员只使用一个数据库实例更为实际。不过,你可以在 Docker 中运行该单个实例。我反对过早并行化,而不是反对使用 Docker 本身。
I don’t recommend using containers unless you absolutely need to minimize your integration tests’ execution time. Again, it’s more practical to have just one database instance per developer. You can run that single instance in Docker, though. I advocate against premature parallelization, not the use of Docker per se.
有四种选项可以清理测试运行之间剩余的数据:
There are four options to clean up leftover data between test runs:
不需要单独的拆卸阶段;将该阶段作为安排部分的一部分来实现。
There’s no need for a separate teardown phase; implement that phase as part of the arrange section.
数据删除本身必须按照特定顺序进行,以遵守数据库的外键约束。我有时会看到人们使用复杂的算法来找出表之间的关系并自动生成删除脚本,甚至禁用所有完整性约束并在之后重新启用它们。这是不必要的。手动编写 SQL 脚本:它更简单,并且可以让您更精细地控制删除过程。
The data removal itself must be done in a particular order, to honor the database’s foreign key constraints. I sometimes see people use sophisticated algorithms to figure out relationships between tables and automatically generate the deletion script or even disable all integrity constraints and re-enable them afterward. This is unnecessary. Write the SQL script manually: it’s simpler and gives you more granular control over the deletion process.
为所有集成测试引入一个基类,并将删除脚本放在那里。有了这样的基类,您将在每次测试开始时自动运行该脚本,如下面的清单所示。
Introduce a base class for all integration tests, and put the deletion script there. With such a base class, you will have the script run automatically at the start of each test, as shown in the following listing.
公共抽象类 IntegrationTests
{
私有 const 字符串 ConnectionString = “...”;
受保护的 IntegrationTests()
{
清除数据库();
}
私有无效清除数据库()
{
字符串查询 =
“从 dbo.[用户] 中删除”;” + 1
“从 dbo.公司中删除”; 1
使用(var 连接 = 新的 SqlConnection(ConnectionString))
{
var 命令 = new SqlCommand(查询,连接)
{
命令类型 = 命令类型.文本
};
连接.打开();
命令.ExecuteNonQuery();
}
}
}public abstract class IntegrationTests
{
private const string ConnectionString = "...";
protected IntegrationTests()
{
ClearDatabase();
}
private void ClearDatabase()
{
string query =
"DELETE FROM dbo.[User];" + 1
"DELETE FROM dbo.Company;"; 1
using (var connection = new SqlConnection(ConnectionString))
{
var command = new SqlCommand(query, connection)
{
CommandType = CommandType.Text
};
connection.Open();
command.ExecuteNonQuery();
}
}
}
删除脚本必须删除所有常规数据,但不删除任何参考数据。参考数据以及数据库架构的其余部分应仅由迁移控制。
The deletion script must remove all regular data but none of the reference data. Reference data, along with the rest of the database schema, should be controlled solely by migrations.
另一种将集成测试彼此隔离的方法是将数据库替换为内存中的类似物,例如 SQLite。内存数据库似乎很有用,因为它们
Another way to isolate integration tests from each other is by replacing the database with an in-memory analog, such as SQLite. In-memory databases can seem beneficial because they
由于内存数据库不是共享依赖项,因此集成测试实际上变成了单元测试(假设数据库是项目中唯一管理的依赖项),类似于第 10.3.1 节中描述的容器方法。
Because in-memory databases aren’t shared dependencies, integration tests in effect become unit tests (assuming the database is the only managed dependency in the project), similar to the approach with containers described in section 10.3.1.
尽管有这些好处,但我不建议使用内存数据库,因为它们在功能方面与常规数据库不一致。这又是生产环境和测试环境不匹配的问题。由于常规数据库和内存数据库之间的差异,您的测试很容易遇到误报或(更糟!)漏报。您永远不会通过这样的测试获得良好的保护,而且无论如何都必须手动进行大量回归测试。
In spite of all these benefits, I don’t recommend using in-memory databases because they aren’t consistent functionality-wise with regular databases. This is, once again, the problem of a mismatch between production and test environments. Your tests can easily run into false positives or (worse!) false negatives due to the differences between the regular and in-memory databases. You’ll never gain good protection with such tests and will have to do a lot of regression testing manually anyway.
在测试和生产中使用相同的数据库管理系统 (DBMS)。版本不同通常没问题,但供应商必须保持不变。
Use the same database management system (DBMS) in tests as in production. It’s usually fine for the version or edition to differ, but the vendor must remain the same.
集成测试很快就会变得过于庞大,从而失去可维护性指标。重要的是让集成测试尽可能简短,但不要将它们相互耦合或影响可读性。即使是最短的测试也不应该相互依赖。它们还应该保留测试场景的完整上下文,并且不应该要求您检查测试类的不同部分来了解正在发生的事情。
Integration tests can quickly grow too large and thus lose ground on the maintainability metric. It’s important to keep integration tests as short as possible but without coupling them to each other or affecting readability. Even the shortest tests shouldn’t depend on one another. They also should preserve the full context of the test scenario and shouldn’t require you to examine different parts of the test class to understand what’s going on.
缩短集成的最佳方法是将技术上与业务无关的部分提取到私有方法或辅助类中。作为额外的好处,您将可以重复使用这些部分。在本节中,我将展示如何缩短测试的所有三个部分:安排、行动和断言。
The best way to shorten integration is by extracting technical, non-business-related bits into private methods or helper classes. As a side bonus, you’ll get to reuse those bits. In this section, I’ll show how to shorten all three sections of the test: arrange, act, and assert.
以下清单显示了为每个部分提供单独的数据库上下文(工作单元)之后我们的集成测试的样子。
The following listing shows how our integration test looks after providing a separate database context (unit of work) for each of its sections.
[事实]
公共无效 Changing_email_from_corporate_to_non_corporate()
{
// 安排
用户用户;
使用(var context = new CrmContext(ConnectionString))
{
var userRepository = new UserRepository(context);
var companyRepository = new CompanyRepository(上下文);
用户 = 新用户(0,“user@mycorp.com”,
用户类型.员工,false);
用户存储库.保存用户(用户);
var company = new Company("mycorp.com", 1);
companyRepository.保存公司(公司);
上下文.保存更改();
}
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
字符串结果;
使用(var context = new CrmContext(ConnectionString))
{
var sut = new UserController(
上下文、messageBus、loggerMock.Object);
// 行为
结果 = sut.ChangeEmail(用户.UserId,“new@gmail.com”);
}
// 断言
Assert.Equal("OK",结果);
使用(var context = new CrmContext(ConnectionString))
{
var userRepository = new UserRepository(context);
var companyRepository = new CompanyRepository(上下文);
用户 userFromDb = userRepository.GetUserById(用户.UserId);
断言.Equal("new@gmail.com", userFromDb.Email);
断言.等于(用户类型.客户,用户来自数据库.类型);
公司 companyFromDb = companyRepository.GetCompany();
断言.等于(0,companyFromDb.NumberOfEmployees);
busSpy.应该发送的消息数量(1)
.WithEmailChangedMessage(用户.UserId,“new@gmail.com”);
loggerMock.验证(
x => x.UserType已改变(
用户.用户ID,用户类型.员工,用户类型.客户),
次.一次);
}
}[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
// Arrange
User user;
using (var context = new CrmContext(ConnectionString))
{
var userRepository = new UserRepository(context);
var companyRepository = new CompanyRepository(context);
user = new User(0, "user@mycorp.com",
UserType.Employee, false);
userRepository.SaveUser(user);
var company = new Company("mycorp.com", 1);
companyRepository.SaveCompany(company);
context.SaveChanges();
}
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
string result;
using (var context = new CrmContext(ConnectionString))
{
var sut = new UserController(
context, messageBus, loggerMock.Object);
// Act
result = sut.ChangeEmail(user.UserId, "new@gmail.com");
}
// Assert
Assert.Equal("OK", result);
using (var context = new CrmContext(ConnectionString))
{
var userRepository = new UserRepository(context);
var companyRepository = new CompanyRepository(context);
User userFromDb = userRepository.GetUserById(user.UserId);
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);
Company companyFromDb = companyRepository.GetCompany();
Assert.Equal(0, companyFromDb.NumberOfEmployees);
busSpy.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");
loggerMock.Verify(
x => x.UserTypeHasChanged(
user.UserId, UserType.Employee, UserType.Customer),
Times.Once);
}
}
您可能还记得第 3 章的内容,在测试的安排部分之间重用代码的最佳方法是引入私有工厂方法。例如,以下清单创建了一个用户。
As you might remember from chapter 3, the best way to reuse code between the tests’ arrange sections is to introduce private factory methods. For example, the following listing creates a user.
私人用户创建用户(
字符串电子邮件,UserType 类型,bool isEmailConfirmed)
{
使用(var context = new CrmContext(ConnectionString))
{
var 用户 = 新用户(0,电子邮件,类型,是否已确认电子邮件);
var 存储库 = 新 UserRepository (上下文);
存储库.保存用户(用户);
上下文.保存更改();
返回用户;
}
}private User CreateUser(
string email, UserType type, bool isEmailConfirmed)
{
using (var context = new CrmContext(ConnectionString))
{
var user = new User(0, email, type, isEmailConfirmed);
var repository = new UserRepository(context);
repository.SaveUser(user);
context.SaveChanges();
return user;
}
}
您还可以为方法的参数定义默认值,如下所示。
You can also define default values for the method’s arguments, as shown next.
私人用户创建用户(
字符串电子邮件 = “user@mycorp.com”,
用户类型类型 =用户类型.员工,
bool isEmailConfirmed = false )
{
/* ... */
}private User CreateUser(
string email = "user@mycorp.com",
UserType type = UserType.Employee,
bool isEmailConfirmed = false)
{
/* ... */
}
使用默认值,您可以有选择地指定参数,从而进一步缩短测试。有选择地使用参数还可以强调哪些参数与测试场景相关。
With default values, you can specify arguments selectively and thus shorten the test even further. The selective use of arguments also emphasizes which of those arguments are relevant to the test scenario.
用户 user = 创建用户 (
电子邮件:“user@mycorp.com”,
类型:UserType.Employee);User user = CreateUser(
email: "user@mycorp.com",
type: UserType.Employee);
清单 10.9和10.10中显示的模式称为Object Mother。Object Mother是一个帮助创建测试装置(测试运行的对象)的类或方法。
The pattern shown in listings 10.9 and 10.10 is called the Object Mother. The Object Mother is a class or method that helps create test fixtures (objects the test runs against).
还有另一种模式可以帮助实现在安排部分中重用代码的相同目标:测试数据生成器。它的工作原理与 Object Mother 类似,但公开了一个流畅的接口而不是普通方法。以下是测试数据生成器的使用示例:
There’s another pattern that helps achieve the same goal of reusing code in arrange sections: Test Data Builder. It works similarly to Object Mother but exposes a fluent interface instead of plain methods. Here’s a Test Data Builder usage example:
用户 user = new UserBuilder()
.WithEmail("user@mycorp.com")
.WithType(用户类型.员工)
。建造();User user = new UserBuilder()
.WithEmail("user@mycorp.com")
.WithType(UserType.Employee)
.Build();
Test Data Builder 稍微提高了测试的可读性,但需要太多样板。因此,我建议坚持使用 Object Mother(至少在 C# 中,可选参数是语言特性)。
Test Data Builder slightly improves test readability but requires too much boilerplate. For that reason, I recommend sticking to the Object Mother (at least in C#, where you have optional arguments as a language feature).
当您开始提炼测试的要点并将技术细节移至工厂方法时,您将面临将这些方法放在哪里的问题。它们应该与测试放在同一个类中吗?基IntegrationTests类?还是放在单独的辅助类中?
When you start distilling the tests’ essentials and move the technicalities out to factory methods, you face the question of where to put those methods. Should they reside in the same class as the tests? The base IntegrationTests class? Or in a separate helper class?
从简单开始。默认情况下,将工厂方法放在同一个类中。只有当代码重复成为严重问题时,才将它们移到单独的辅助类中。不要将工厂方法放在基类中;将该类保留给必须在每个测试中运行的代码,例如数据清理。
Start simple. Place the factory methods in the same class by default. Move them into separate helper classes only when code duplication becomes a significant issue. Don’t put the factory methods in the base class; reserve that class for code that has to run in every test, such as data cleanup.
集成测试中的每个 act 部分都涉及创建数据库事务或工作单元。清单 10.7中的 act 部分目前如下所示:
Every act section in integration tests involves the creation of a database transaction or a unit of work. This is how the act section currently looks in listing 10.7:
字符串结果;
使用(var context = new CrmContext(ConnectionString))
{
var sut = new UserController(
上下文、messageBus、loggerMock.Object);
// 行为
结果 = sut.ChangeEmail(用户.UserId,“new@gmail.com”);
}string result;
using (var context = new CrmContext(ConnectionString))
{
var sut = new UserController(
context, messageBus, loggerMock.Object);
// Act
result = sut.ChangeEmail(user.UserId, "new@gmail.com");
}
这部分也可以减少。您可以引入一个接受委托的方法,其中包含需要调用哪个控制器函数的信息。然后,该方法将通过创建数据库上下文来修饰控制器调用,如下面的清单所示。
This section can also be reduced. You can introduce a method accepting a delegate with the information of what controller function needs to be invoked. The method will then decorate the controller invocation with the creation of a database context, as shown in the following listing.
私有字符串执行(
Func<UserController, string> func, 1
消息总线 消息总线,
IDomainLogger 记录器)
{
使用(var context = new CrmContext(ConnectionString))
{
var 控制器 = 新的 UserController(
上下文、消息总线、记录器);
返回函数(控制器);
}
}private string Execute(
Func<UserController, string> func, 1
MessageBus messageBus,
IDomainLogger logger)
{
using (var context = new CrmContext(ConnectionString))
{
var controller = new UserController(
context, messageBus, logger);
return func(controller);
}
}
使用这种装饰器方法,您可以将测试的行为部分归结为几行:
With this decorator method, you can boil down the test’s act section to just a couple of lines:
字符串结果 = 执行(
x => x.ChangeEmail(用户.UserId, “new@gmail.com”),
消息总线,loggerMock.Object);string result = Execute(
x => x.ChangeEmail(user.UserId, "new@gmail.com"),
messageBus, loggerMock.Object);
最后,assert 部分也可以缩短。最简单的方法是引入类似于CreateUser和 的辅助方法CreateCompany,如下面的清单所示。
Finally, the assert section can be shortened, too. The easiest way to do that is to introduce helper methods similar to CreateUser and CreateCompany, as shown in the following listing.
用户 userFromDb = QueryUser(user.UserId); 1
断言.Equal("new@gmail.com", userFromDb.Email);
断言.等于(用户类型.客户,用户来自数据库.类型);
公司 companyFromDb = QueryCompany(); 1
Assert.Equal(0, companyFromDb.NumberOfEmployees);User userFromDb = QueryUser(user.UserId); 1
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);
Company companyFromDb = QueryCompany(); 1
Assert.Equal(0, companyFromDb.NumberOfEmployees);
您可以更进一步,为这些数据断言创建一个流畅的接口,类似于您在第 9 章中看到的BusSpy。在 C# 中,可以使用扩展方法在现有域类之上实现流畅的接口,如下面的清单所示。
You can take a step further and create a fluent interface for these data assertions, similar to what you saw in chapter 9 with BusSpy. In C#, a fluent interface on top of existing domain classes can be implemented using extension methods, as shown in the following listing.
公共静态类 UserExternsions
{
公共静态用户ShouldExist(此用户用户)
{
断言.NotNull(用户);
返回用户;
}
公共静态用户 WithEmail(此用户用户,字符串电子邮件)
{
断言.平等(电子邮件,用户.电子邮件);
返回用户;
}
}public static class UserExternsions
{
public static User ShouldExist(this User user)
{
Assert.NotNull(user);
return user;
}
public static User WithEmail(this User user, string email)
{
Assert.Equal(email, user.Email);
return user;
}
}
With this fluent interface, the assertions become much easier to read:
用户 userFromDb = QueryUser(user.UserId);
用户从数据库
.应该存在()
.WithEmail("new@gmail.com")
.WithType(用户类型.客户);
公司 companyFromDb = QueryCompany();
公司数据库
.应该存在()
.员工人数(0);User userFromDb = QueryUser(user.UserId);
userFromDb
.ShouldExist()
.WithEmail("new@gmail.com")
.WithType(UserType.Customer);
Company companyFromDb = QueryCompany();
companyFromDb
.ShouldExist()
.WithNumberOfEmployees(0);
经过前面的所有简化,集成测试变得更易读,因此也更易于维护。不过有一个缺点:测试现在总共使用五个数据库事务(工作单元),而之前只使用三个,如下面的清单所示。
After all the simplifications made earlier, the integration test has become more readable and, therefore, more maintainable. There’s one drawback, though: the test now uses a total of five database transactions (units of work), where before it used only three, as shown in the following listing.
公共类 UserControllerTests: IntegrationTests
{
[事实]
公共无效 Changing_email_from_corporate_to_non_corporate()
{
// 安排
用户 user = 创建用户 ( 1
电子邮件:“user@mycorp.com”,
类型:UserType.Employee);
创建公司(“mycorp.com”,1); 1
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
// 行为
字符串结果 = 执行( 1
x => x.ChangeEmail(用户.UserId, “new@gmail.com”),
消息总线,loggerMock.Object);
// 断言
Assert.Equal("OK",结果);
用户 userFromDb = QueryUser(user.UserId); 1
用户从数据库
.应该存在()
.WithEmail("new@gmail.com")
.WithType(用户类型.客户);
公司 companyFromDb = QueryCompany(); 1
公司数据库
.应该存在()
.员工人数(0);
busSpy.应该发送的消息数量(1)
.WithEmailChangedMessage(用户.UserId,“new@gmail.com”);
loggerMock.验证(
x => x.UserType已改变(
用户.用户ID,用户类型.员工,用户类型.客户),
次.一次);
}
}public class UserControllerTests : IntegrationTests
{
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
// Arrange
User user = CreateUser( 1
email: "user@mycorp.com",
type: UserType.Employee);
CreateCompany("mycorp.com", 1); 1
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
// Act
string result = Execute( 1
x => x.ChangeEmail(user.UserId, "new@gmail.com"),
messageBus, loggerMock.Object);
// Assert
Assert.Equal("OK", result);
User userFromDb = QueryUser(user.UserId); 1
userFromDb
.ShouldExist()
.WithEmail("new@gmail.com")
.WithType(UserType.Customer);
Company companyFromDb = QueryCompany(); 1
companyFromDb
.ShouldExist()
.WithNumberOfEmployees(0);
busSpy.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");
loggerMock.Verify(
x => x.UserTypeHasChanged(
user.UserId, UserType.Employee, UserType.Customer),
Times.Once);
}
}
数据库事务数量的增加是一个问题吗?如果是,您能做些什么呢?额外的数据库上下文在某种程度上是一个问题,因为它们会使测试变慢,但对此我们能做的不多。这是在有价值的测试的不同方面之间进行权衡的另一个例子:这次是在快速反馈和可维护性之间。在这种特殊情况下,做出这种权衡并用性能换取可维护性是值得的。性能下降不应该那么明显,尤其是当数据库位于开发人员的机器上时。同时,可维护性的收益相当可观。
Is the increased number of database transactions a problem? And, if so, what can you do about it? The additional database contexts are a problem to some degree because they make the test slower, but there’s not much that can be done about it. It’s another example of a trade-off between different aspects of a valuable test: this time between fast feedback and maintainability. It’s worth it to make that trade-off and exchange performance for maintainability in this particular case. The performance degradation shouldn’t be that significant, especially when the database is located on the developer’s machine. At the same time, the gains in maintainability are quite substantial.
在本章的最后一部分,我想回答与数据库测试相关的常见问题,并简要重申第 8 章和第 9 章中提出的一些重要观点。
In this last section of the chapter, I’d like to answer common questions related to database testing, as well as briefly reiterate some important points made in chapters 8 and 9.
在过去的几章中,我们一直在处理更改用户电子邮件的示例场景。此场景是写入操作(在数据库和其他进程外依赖项中留下副作用的操作)的示例。大多数应用程序都包含写入和读取操作。读取操作的一个示例是将用户信息返回到外部客户端。您应该测试写入和读取吗?
Throughout the last several chapters, we’ve worked with a sample scenario of changing a user email. This scenario is an example of a write operation (an operation that leaves a side effect in the database and other out-of-process dependencies). Most applications contain both write and read operations. An example of a read operation would be returning the user information to the external client. Should you test both writes and reads?
彻底测试写入至关重要,因为风险很高。写入操作中的错误通常会导致数据损坏,这不仅会影响您的数据库,还会影响也包括外部应用程序。覆盖写入的测试非常有价值,因为它们可以防止此类错误。
It’s crucial to thoroughly test writes, because the stakes are high. Mistakes in write operations often lead to data corruption, which can affect not only your database but also external applications. Tests that cover writes are highly valuable due to the protection they provide against such mistakes.
但读取操作则不然:读取操作中的错误通常不会造成如此严重的后果。因此,测试读取的门槛应高于写入。只测试最复杂或最重要的读取操作;忽略其余操作。
This is not the case for reads: a bug in a read operation usually doesn’t have consequences that are as detrimental. Therefore, the threshold for testing reads should be higher than that for writes. Test only the most complex or important read operations; disregard the rest.
请注意,读取中也不需要域模型。域建模的主要目标之一是封装。您可能还记得第 5 章和第 6 章的内容,封装是为了在任何更改的情况下保持数据一致性。缺乏数据更改使得读取的封装毫无意义。事实上,在读取中,您也不需要成熟的 ORM(如 NHibernate 或 Entity Framework)。您最好使用纯 SQL,由于绕过了不必要的抽象层,它在性能上优于 ORM(图 10.7)。
Note that there’s also no need for a domain model in reads. One of the main goals of domain modeling is encapsulation. And, as you might remember from chapters 5 and 6, encapsulation is about preserving data consistency in light of any changes. The lack of data changes makes encapsulation of reads pointless. In fact, you don’t need a fully fledged ORM such as NHibernate or Entity Framework in reads, either. You are better off using plain SQL, which is superior to an ORM performance-wise, thanks to bypassing unnecessary layers of abstraction (figure 10.7).
由于读取中几乎没有任何抽象层(域模型就是这样的一个层),因此单元测试在那里没有任何用处。如果您决定测试读取,请使用真实数据库上的集成测试进行测试。
Because there are hardly any abstraction layers in reads (the domain model is one such layer), unit tests aren’t of any use there. If you decide to test your reads, do so using integration tests on a real database.
存储库在数据库之上提供了有用的抽象。以下是来自我们的示例 CRM 项目的一个使用示例:
Repositories provide a useful abstraction on top of the database. Here’s a usage example from our sample CRM project:
用户用户 = _userRepository.GetUserById(用户Id); _userRepository.保存用户(用户);
User user = _userRepository.GetUserById(userId); _userRepository.SaveUser(user);
您是否应该独立于其他集成测试来测试存储库?测试存储库如何将域对象映射到数据库似乎很有好处。毕竟,此功能存在很大的出错空间。尽管如此,由于维护成本高且对回归的保护较差,此类测试对您的测试套件来说还是净损失。让我们更详细地讨论这两个缺点。
Should you test repositories independently of other integration tests? It might seem beneficial to test how repositories map domain objects to the database. After all, there’s significant room for a mistake in this functionality. Still, such tests are a net loss to your test suite due to high maintenance costs and inferior protection against regressions. Let’s discuss these two drawbacks in more detail.
存储库属于第 7 章代码类型图(图 10.8)中的控制器象限。它们表现出的复杂性很小,并与进程外依赖项(数据库)进行通信。进程外依赖项的存在增加了测试的维护成本。
Repositories fall into the controllers quadrant on the types-of-code diagram from chapter 7 (figure 10.8). They exhibit little complexity and communicate with an out-of-process dependency: the database. The presence of that out-of-process dependency is what inflates the tests’ maintenance costs.
就维护成本而言,测试存储库与常规集成测试承担着同样的负担。但这种测试能带来同等的回报吗?不幸的是,并非如此。
When it comes to maintenance costs, testing repositories carries the same burden as regular integration tests. But does such testing provide an equal amount of benefits in return? Unfortunately, it doesn’t.
存储库没有那么多的复杂性,而且许多防止回归的好处与常规集成测试提供的好处重叠。因此,对存储库的测试并没有带来足够大的价值。
Repositories don’t carry that much complexity, and a lot of the gains in protection against regressions overlap with the gains provided by regular integration tests. Thus, tests on repositories don’t add significant enough value.
测试存储库的最佳做法是将其所具有的少量复杂性提取到一个自包含算法中,并专门测试该算法。这就是前面章节中UserFactory和CompanyFactory的目的。这两个类执行了所有映射,而无需任何协作者(进程外或其他方式)。存储库(Database类)仅包含简单的 SQL 查询。
The best course of action in testing a repository is to extract the little complexity it has into a self-contained algorithm and test that algorithm exclusively. That’s what UserFactory and CompanyFactory were for in earlier chapters. These two classes performed all the mappings without taking on any collaborators, out-of-process or otherwise. The repositories (the Database class) only contained simple SQL queries.
不幸的是,使用 ORM 时,数据映射(以前由工厂执行)和与数据库的交互(以前由 执行Database)之间的这种分离是不可能的。如果不调用数据库,您就无法测试 ORM 映射,至少在不影响重构阻力的情况下是如此。因此,请遵循以下准则:不要直接测试存储库,而应将其作为总体集成测试套件的一部分。
Unfortunately, such a separation between data mapping (formerly performed by the factories) and interactions with the database (formerly performed by Database) is impossible when using an ORM. You can’t test your ORM mappings without calling the database, at least not without compromising resistance to refactoring. Therefore, adhere to the following guideline: don’t test repositories directly, only as part of the overarching integration test suite.
也不要EventDispatcher单独测试(此类将域事件转换为对非托管依赖项的调用)。维护复杂的模拟机制所需的成本太高,而对回归的保护却收效甚微。
Don’t test EventDispatcher separately, either (this class converts domain events into calls to unmanaged dependencies). There are too few gains in protection against regressions in exchange for the too-high costs required to maintain the complicated mock machinery.
精心设计的数据库测试可以防止出现错误。根据我的经验,它们是最有效的工具之一,没有它们,您不可能对您的软件充满信心。当您重构数据库、切换 ORM 或更改数据库供应商时,此类测试将大有帮助。
Well-crafted tests against the database provide bulletproof protection from bugs. In my experience, they are one of the most effective tools, without which it’s impossible to gain full confidence in your software. Such tests help enormously when you refactor the database, switch the ORM, or change the database vendor.
实际上,我们的示例项目在本章前面已过渡到 Entity Framework ORM,我只需要修改集成测试中的几行代码即可确保转换成功。直接使用托管依赖项的集成测试是防止大规模重构导致错误的最有效方法。
In fact, our sample project transitioned to the Entity Framework ORM earlier in this chapter, and I only needed to modify a couple of lines of code in the integration test to make sure the transition was successful. Integration tests working directly with managed dependencies are the most efficient way to protect against bugs resulting from large-scale refactorings.
本书的最后一部分介绍了常见的单元测试反模式。您很可能在过去遇到过其中一些。不过,使用第 4 章中定义的良好单元测试的四个属性来研究这个主题还是很有趣的。您可以使用这些属性来分析任何单元测试概念或模式;反模式也不例外。
This final part of the book covers common unit testing anti-patterns. You’ve most likely encountered some of them in the past. Still, it’s interesting to look at this topic using the four attributes of a good unit test defined in chapter 4. You can use those attributes to analyze any unit testing concepts or patterns; anti-patterns aren’t an exception.
本章涵盖
This chapter covers
本章汇集了与本书前面部分不太相关的主题(主要是反模式),这些主题单独列出会更好。反模式是针对反复出现的问题的常见解决方案,表面上看似合适,但会导致后续问题。
This chapter is an aggregation of lesser related topics (mostly anti-patterns) that didn’t fit in earlier in the book and are better served on their own. An anti-pattern is a common solution to a recurring problem that looks appropriate on the surface but leads to problems further down the road.
您将学习如何在测试中使用时间,如何识别和避免诸如私有方法的单元测试、代码污染、模拟具体类等反模式。这些主题中的大多数都遵循第 2 部分中描述的第一个原则。尽管如此,它们还是值得明确阐述的。您可能在过去听说过至少其中一些反模式,但本章将帮助您将这些点连接起来,并了解它们所基于的基础。
You will learn how to work with time in tests, how to identify and avoid such anti-patterns as unit testing of private methods, code pollution, mocking concrete classes, and more. Most of these topics follow from the first principles described in part 2. Still, they are well worth spelling out explicitly. You’ve probably heard of at least some of these anti-patterns in the past, but this chapter will help you connect the dots, so to speak, and see the foundations they are based on.
说到单元测试,最常见的问题之一就是如何测试私有方法。简短的回答是,你根本不应该这样做,但这个话题有很多细微差别。
When it comes to unit testing, one of the most commonly asked questions is how to test a private method. The short answer is that you shouldn’t do so at all, but there’s quite a bit of nuance to this topic.
仅仅为了进行单元测试而暴露您原本要保持私有的方法违反了我们在第 5 章中讨论的基本原则之一:仅测试可观察的行为。暴露私有方法会导致测试与实现细节耦合,并最终损害测试对重构的抵抗力——这是四个指标中最重要的指标。(再次强调,所有四个指标都是针对回归的保护、对重构的抵抗力、快速反馈和可维护性。)不要直接测试私有方法,而要间接测试它们,作为总体可观察行为的一部分。
Exposing methods that you would otherwise keep private just to enable unit testing violates one of the foundational principles we discussed in chapter 5: testing observable behavior only. Exposing private methods leads to coupling tests to implementation details and, ultimately, damaging your tests’ resistance to refactoring—the most important metric of the four. (All four metrics, once again, are protection against regressions, resistance to refactoring, fast feedback, and maintainability.) Instead of testing private methods directly, test them indirectly, as part of the overarching observable behavior.
有时,私有方法过于复杂,将其作为可观察行为的一部分进行测试无法提供足够的覆盖范围。假设可观察行为已经具有合理的测试覆盖范围,则可能存在两个问题:
Sometimes, the private method is too complex, and testing it as part of the observable behavior doesn’t provide sufficient coverage. Assuming the observable behavior already has reasonable test coverage, there can be two issues at play:
我们用一个例子来说明第二个问题。
Let’s illustrate the second issue with an example.
公开课秩序
{
私人客户_customer;
私有列表<产品> _products;
公共字符串GenerateDescription()
{
返回 $"客户名称:{_customer.Name}, " +
$"产品总数:{_products.Count}, " +
$"总价:{GetPrice()}"; 1
}
私有小数 GetPrice() 2
{
decimal basePrice = /* 根据 _products 计算 */;
十进制折扣 = /* 根据 _customer 计算 */;
十进制税 = /* 根据 _products 计算 */;
返回基本价格 - 折扣+税金;
}
}public class Order
{
private Customer _customer;
private List<Product> _products;
public string GenerateDescription()
{
return $"Customer name: {_customer.Name}, " +
$"total number of products: {_products.Count}, " +
$"total price: {GetPrice()}"; 1
}
private decimal GetPrice() 2
{
decimal basePrice = /* Calculate based on _products */;
decimal discounts = /* Calculate based on _customer */;
decimal taxes = /* Calculate based on _products */;
return basePrice - discounts + taxes;
}
}
该GenerateDescription()方法非常简单:它返回订单的通用描述。但它使用私有GetPrice()方法,而私有方法则复杂得多:它包含重要的业务逻辑,需要进行彻底测试。该逻辑是缺失的抽象。不要公开该方法,而是GetPrice通过将其提取到单独的类中来使该抽象显式化,如下一个清单所示。
The GenerateDescription() method is quite simple: it returns a generic description of the order. But it uses the private GetPrice() method, which is much more complex: it contains important business logic and needs to be thoroughly tested. That logic is a missing abstraction. Instead of exposing the GetPrice method, make this abstraction explicit by extracting it into a separate class, as shown in the next listing.
公开课秩序
{
私人客户_customer;
私有列表<产品> _products;
公共字符串GenerateDescription()
{
var calc = new PriceCalculator();
返回 $"客户名称:{_customer.Name}, " +
$"产品总数:{_products.Count}, " +
$“总价:{calc.Calculate(_customer,_products)}”;
}
}
公共类价格计算器
{
公共小数计算(客户客户,列表 <产品> 产品)
{
decimal basePrice = /* 根据产品计算 */;
十进制折扣 = /* 根据客户计算 */;
十进制税 = /* 根据产品计算 */;
返回基本价格 - 折扣+税金;
}
}public class Order
{
private Customer _customer;
private List<Product> _products;
public string GenerateDescription()
{
var calc = new PriceCalculator();
return $"Customer name: {_customer.Name}, " +
$"total number of products: {_products.Count}, " +
$"total price: {calc.Calculate(_customer, _products)}";
}
}
public class PriceCalculator
{
public decimal Calculate(Customer customer, List<Product> products)
{
decimal basePrice = /* Calculate based on products */;
decimal discounts = /* Calculate based on customer */;
decimal taxes = /* Calculate based on products */;
return basePrice - discounts + taxes;
}
}
现在您可以PriceCalculator独立于进行测试Order。您还可以使用基于输出(功能)的单元测试样式,因为PriceCalculator没有任何隐藏的输入或输出。有关单元测试样式的更多信息,请参阅第 6 章。
Now you can test PriceCalculator independently of Order. You can also use the output-based (functional) style of unit testing, because PriceCalculator doesn’t have any hidden inputs or outputs. See chapter 6 for more information about styles of unit testing.
永远不要测试私有方法的规则也有例外。要理解这些例外,我们需要重新审视代码的公开性和第 5 章中的目的。表 11.1总结了这种关系(您已经在第 5 章中看到了该表;我将其复制到此处以方便阅读)。
There are exceptions to the rule of never testing private methods. To understand those exceptions, we need to revisit the relationship between the code’s publicity and purpose from chapter 5. Table 11.1 sums up that relationship (you already saw this table in chapter 5; I’m copying it here for convenience).
|
可观察的行为 Observable behavior |
实施细节 Implementation detail |
|
|---|---|---|
| 民众 | 好的 | 坏的 |
| 私人的 | 不适用 | 好的 |
您可能还记得第 5 章的内容,将可观察行为设为公共,将实现细节设为私有,这样可以得到设计良好的 API。另一方面,泄露实现细节会破坏代码的封装性。N/A表中标记了可观察行为和私有方法的交集,因为要使某个方法成为可观察行为的一部分,它必须由客户端代码使用,而如果该方法是私有的,这是不可能的。
As you might remember from chapter 5, making the observable behavior public and implementation details private results in a well-designed API. On the other hand, leaking implementation details damages the code’s encapsulation. The intersection of observable behavior and private methods is marked N/A in the table because for a method to become part of observable behavior, it has to be used by the client code, which is impossible if that method is private.
请注意,测试私有方法本身并不坏。它之所以不好,只是因为这些私有方法是实现细节的代理。测试实现细节最终会导致测试脆弱性。话虽如此,在极少数情况下,方法既是私有的,又是可观察行为的一部分(因此表 11.1N/A中的标记并不完全正确)。
Note that testing private methods isn’t bad in and of itself. It’s only bad because those private methods are a proxy for implementation details. Testing implementation details is what ultimately leads to test brittleness. Having that said, there are rare cases where a method is both private and part of observable behavior (and thus the N/A marking in table 11.1 isn’t entirely correct).
让我们以一个管理信用查询的系统为例。新的查询每天一次批量加载到数据库中。然后管理员逐一审查这些查询并决定是否批准。以下是该类Inquiry在该系统中的样子。
Let’s take a system that manages credit inquiries as an example. New inquiries are bulk-loaded directly into the database once a day. Administrators then review those inquiries one by one and decide whether to approve them. Here’s how the Inquiry class might look in that system.
公开课咨询
{
公共 bool IsApproved { 获取; 私人设置; }
公共 DateTime?TimeApproved { 获取; 私有设置; }
私人询问( 1
bool isApproved,DateTime?timeApproved) 1
{
如果(isApproved && !timeApproved.HasValue)
抛出新的异常();
已批准=已批准;
批准时间=批准时间;
}
公共无效批准(DateTime现在)
{
如果(已批准)
返回;
已批准 = 真;
批准时间 = 现在;
}
}public class Inquiry
{
public bool IsApproved { get; private set; }
public DateTime? TimeApproved { get; private set; }
private Inquiry( 1
bool isApproved, DateTime? timeApproved) 1
{
if (isApproved && !timeApproved.HasValue)
throw new Exception();
IsApproved = isApproved;
TimeApproved = timeApproved;
}
public void Approve(DateTime now)
{
if (IsApproved)
return;
IsApproved = true;
TimeApproved = now;
}
}
私有构造函数之所以是私有的,是因为该类是由对象关系映射 (ORM) 库从数据库恢复的。该 ORM 不需要公共构造函数;它完全可以使用私有构造函数。同时,我们的系统也不需要构造函数,因为它不负责创建这些查询。
The private constructor is private because the class is restored from the database by an object-relational mapping (ORM) library. That ORM doesn’t need a public constructor; it may well work with a private one. At the same time, our system doesn’t need a constructor, either, because it’s not responsible for the creation of those inquiries.
既然无法实例化类的对象,那么如何测试该类呢Inquiry?一方面,批准逻辑显然很重要,因此应该进行单元测试。但另一方面,将构造函数公开会违反不公开私有方法的规则。
How do you test the Inquiry class given that you can’t instantiate its objects? On the one hand, the approval logic is clearly important and thus should be unit tested. But on the other, making the constructor public would violate the rule of not exposing private methods.
Inquiry的构造函数是私有方法和可观察行为的一部分的示例。此构造函数履行与 ORM 的契约,而且它是私有的这一事实并不会降低该契约的重要性:如果没有它,ORM 将无法从数据库恢复查询。
Inquiry’s constructor is an example of a method that is both private and part of the observable behavior. This constructor fulfills the contract with the ORM, and the fact that it’s private doesn’t make that contract less important: the ORM wouldn’t be able to restore inquiries from the database without it.
因此,Inquiry在这种特殊情况下,将 的构造函数公开不会导致测试脆弱性。事实上,可以说,这将使类的 API 更接近精心设计。只需确保构造函数包含维护其封装所需的所有先决条件。在清单 11.3中,这样的先决条件是要求在所有已批准的查询中都有批准时间。
And so, making Inquiry’s constructor public won’t lead to test brittleness in this particular case. In fact, it will arguably bring the class’s API closer to being well-designed. Just make sure the constructor contains all the preconditions required to maintain its encapsulation. In listing 11.3, such a precondition is the requirement to have the approval time in all approved inquiries.
或者,如果您希望尽可能缩小类的公共 API 界面,您可以Inquiry在测试中通过反射进行实例化。虽然这看起来像是一种 hack,但您只是遵循了 ORM,它也在幕后使用反射。
Alternatively, if you prefer to keep the class’s public API surface as small as possible, you can instantiate Inquiry via reflection in tests. Although this looks like a hack, you are just following the ORM, which also uses reflection behind the scenes.
另一种常见的反模式是仅出于单元测试目的而公开私有状态。此处的指导原则与私有方法相同:不要公开您原本要保密的状态 — 仅测试可观察的行为。让我们看看以下清单。
Another common anti-pattern is exposing private state for the sole purpose of unit testing. The guideline here is the same as with private methods: don’t expose state that you would otherwise keep private—test observable behavior only. Let’s take a look at the following listing.
公开课 客户
{
私人客户状态 _status = 1
客户状态.常规; 1
公共无效提升()
{
_status = 客户状态.首选;
}
公共十进制 GetDiscount()
{
返回 _status == CustomerStatus.Preferred ? 0.05m : 0m;
}
}
公共枚举客户状态
{
常规的,
首选
}public class Customer
{
private CustomerStatus _status = 1
CustomerStatus.Regular; 1
public void Promote()
{
_status = CustomerStatus.Preferred;
}
public decimal GetDiscount()
{
return _status == CustomerStatus.Preferred ? 0.05m : 0m;
}
}
public enum CustomerStatus
{
Regular,
Preferred
}
此示例显示了一个Customer类别。每个客户的状态都是Regular,然后可以升级为Preferred,此时他们可获得所有商品 5% 的折扣。
This example shows a Customer class. Each customer is created in the Regular status and then can be promoted to Preferred, at which point they get a 5% discount on everything.
您将如何测试该Promote()方法?该方法的副作用是更改字段_status,但该字段本身是私有的,因此在测试中不可用。一个诱人的解决方案是将该字段设为公共。毕竟,状态的改变不是调用的最终目的吗Promote()?
How would you test the Promote() method? This method’s side effect is a change of the _status field, but the field itself is private and thus not available in tests. A tempting solution would be to make this field public. After all, isn’t the change of status the ultimate goal of calling Promote()?
但那将是一种反模式。请记住,您的测试应该以与生产代码完全相同的方式与被测系统 (SUT) 交互,并且不应具有任何特殊权限。在清单 11.4中,该_status字段对生产代码隐藏,因此不是 SUT 可观察行为的一部分。公开该字段会导致测试与实现细节耦合。Promote()那么如何测试呢?
That would be an anti-pattern, however. Remember, your tests should interact with the system under test (SUT) exactly the same way as the production code and shouldn’t have any special privileges. In listing 11.4, the _status field is hidden from the production code and thus is not part of the SUT’s observable behavior. Exposing that field would result in coupling tests to implementation details. How to test Promote(), then?
相反,你应该做的是看看生产代码如何使用这个类。在这个特定的例子中,生产代码并不关心客户的状态;否则,该字段将是公开的。生产代码唯一关心的信息是客户在促销后获得的折扣。这就是你需要在测试中验证的内容。你需要检查
What you should do, instead, is look at how the production code uses this class. In this particular example, the production code doesn’t care about the customer’s status; otherwise, that field would be public. The only information the production code does care about is the discount the customer gets after the promotion. And so that’s what you need to verify in tests. You need to check that
稍后,如果生产代码开始使用客户状态字段,您也可以在测试中耦合该字段,因为它将正式成为 SUT 可观察行为的一部分。
Later, if the production code starts using the customer status field, you’d be able to couple to that field in tests too, because it would officially become part of the SUT’s observable behavior.
为了可测试性而扩大公共 API 表面是一种不好的做法。
Widening the public API surface for the sake of testability is a bad practice.
将领域知识泄露给测试是另一种相当常见的反模式。它通常发生在涉及复杂算法的测试中。让我们以以下(诚然,不那么复杂)计算算法为例:
Leaking domain knowledge to tests is another quite common anti-pattern. It usually takes place in tests that cover complex algorithms. Let’s take the following (admittedly, not that complex) calculation algorithm as an example:
公共静态类计算器
{
公共静态 int Add(int 值 1,int 值 2)
{
返回值1 + 值2;
}
}public static class Calculator
{
public static int Add(int value1, int value2)
{
return value1 + value2;
}
}
This listing shows an incorrect way to test it.
公共类计算器测试
{
[事实]
公共无效添加两个数字()
{
int值1 = 1;
int值2 = 3;
int 预期 = 值 1 + 值 2; 1
int 实际 = 计算器.添加(值1,值2);
断言.等于(预期,实际);
}
}public class CalculatorTests
{
[Fact]
public void Adding_two_numbers()
{
int value1 = 1;
int value2 = 3;
int expected = value1 + value2; 1
int actual = Calculator.Add(value1, value2);
Assert.Equal(expected, actual);
}
}
您还可以参数化测试,以几乎不增加任何额外成本地添加更多测试用例。
You could also parameterize the test to throw in a couple more test cases at almost no additional cost.
公共类计算器测试
{
[理论]
[内联数据(1,3)]
[内联数据(11,33)]
[内联数据(100,500)]
public void Adding_two_numbers(int 值1,int 值2)
{
int 预期 = 值 1 + 值 2; 1
int 实际 = 计算器.添加(值1,值2);
断言.等于(预期,实际);
}
}public class CalculatorTests
{
[Theory]
[InlineData(1, 3)]
[InlineData(11, 33)]
[InlineData(100, 500)]
public void Adding_two_numbers(int value1, int value2)
{
int expected = value1 + value2; 1
int actual = Calculator.Add(value1, value2);
Assert.Equal(expected, actual);
}
}
清单 11.5和11.6乍一看没什么问题,但实际上它们是反模式的例子:这些测试从生产代码中复制了算法实现。当然,这似乎不是什么大问题。毕竟,它只有一行。但那只是因为示例相当简单。我见过涵盖复杂算法的测试,它们只是在安排部分重新实现这些算法。它们基本上是从生产代码中复制粘贴的。
Listings 11.5 and 11.6 look fine at first, but they are, in fact, examples of the anti-pattern: these tests duplicate the algorithm implementation from the production code. Of course, it might not seem like a big deal. After all, it’s just one line. But that’s only because the example is rather simplified. I’ve seen tests that covered complex algorithms and did nothing but reimplement those algorithms in the arrange part. They were basically a copy-paste from the production code.
这些测试是与实现细节耦合的另一个例子。它们在抗重构指标上的得分几乎为零,因此毫无价值。这样的测试无法区分合法失败和误报。如果算法的更改导致这些测试失败,团队很可能只是将该算法的新版本复制到测试中,甚至不试图找出根本原因(这是可以理解的,因为测试首先只是算法的重复)。
These tests are another example of coupling to implementation details. They score almost zero on the metric of resistance to refactoring and are worthless as a result. Such tests don’t have a chance of differentiating legitimate failures from false positives. Should a change in the algorithm make those tests fail, the team would most likely just copy the new version of that algorithm to the test without even trying to identify the root cause (which is understandable, because the tests were a mere duplication of the algorithm in the first place).
那么,如何正确测试算法呢?编写测试时不要暗示任何特定的实现。不要复制算法,而是将其结果硬编码到测试中,如下面的清单所示。
How to test the algorithm properly, then? Don’t imply any specific implementation when writing tests. Instead of duplicating the algorithm, hard-code its results into the test, as shown in the following listing.
公共类计算器测试
{
[理论]
[内联数据(1,3,4 ) ][
内联数据(11,33,44 )][
内联数据(100,500,600)]
public void Adding_two_numbers(int value1, int value2, int expected)
{
int 实际 = 计算器.添加(值1,值2);
断言.等于(预期,实际);
}
}public class CalculatorTests
{
[Theory]
[InlineData(1, 3, 4)]
[InlineData(11, 33, 44)]
[InlineData(100, 500, 600)]
public void Adding_two_numbers(int value1, int value2, int expected)
{
int actual = Calculator.Add(value1, value2);
Assert.Equal(expected, actual);
}
}
乍一看这似乎违反直觉,但在单元测试中,对预期结果进行硬编码是一种很好的做法。硬编码值的重要部分是使用 SUT 以外的其他东西预先计算它们,最好是在领域专家的帮助下。当然,这只有在算法足够复杂的情况下才可以(我们都是将两个数字相加的专家)。或者,如果您重构旧版应用程序,您可以让旧版代码生成这些结果,然后在测试中将它们用作预期值。
It can seem counterintuitive at first, but hardcoding the expected result is a good practice when it comes to unit testing. The important part with the hardcoded values is to precalculate them using something other than the SUT, ideally with the help of a domain expert. Of course, that’s only if the algorithm is complex enough (we are all experts at summing up two numbers). Alternatively, if you refactor a legacy application, you can have the legacy code produce those results and then use them as expected values in tests.
下一个反模式是代码污染。
The next anti-pattern is code pollution.
代码污染是添加仅供测试需要的生产代码。
Code pollution is adding production code that’s only needed for testing.
代码污染通常以各种类型的开关形式出现。我们以记录器为例。
Code pollution often takes the form of various types of switches. Let’s take a logger as an example.
公共类记录器
{
私有只读 bool _isTestEnvironment;
公共记录器(bool isTestEnvironment) 1
{
_是测试环境 = 是测试环境;
}
公共无效日志(字符串文本)
{
如果(_isTestEnvironment) 1
返回;
/* 记录文本 */
}
}
公共类控制器
{
公共无效SomeMethod(记录器记录器)
{
logger.Log("某个方法被调用");
}
}public class Logger
{
private readonly bool _isTestEnvironment;
public Logger(bool isTestEnvironment) 1
{
_isTestEnvironment = isTestEnvironment;
}
public void Log(string text)
{
if (_isTestEnvironment) 1
return;
/* Log the text */
}
}
public class Controller
{
public void SomeMethod(Logger logger)
{
logger.Log("SomeMethod is called");
}
}
在此示例中,Logger有一个构造函数参数,指示该类是否在生产环境中运行。如果是,记录器会将消息记录到文件中;否则,它不执行任何操作。使用这样的布尔开关,您可以在测试运行期间禁用记录器,如以下清单所示。
In this example, Logger has a constructor parameter that indicates whether the class runs in production. If so, the logger records the message into the file; otherwise, it does nothing. With such a Boolean switch, you can disable the logger during test runs, as shown in the following listing.
[事实]
公共无效Some_test()
{
var logger = new Logger(true); 1
var sut = new Controller();
sut.SomeMethod(记录器);
/* 断言 */
}[Fact]
public void Some_test()
{
var logger = new Logger(true); 1
var sut = new Controller();
sut.SomeMethod(logger);
/* assert */
}
代码污染的问题在于,它会混淆测试代码和生产代码,从而增加后者的维护成本。为了避免这种反模式,请将测试代码排除在生产代码库之外。
The problem with code pollution is that it mixes up test and production code and thereby increases the maintenance costs of the latter. To avoid this anti-pattern, keep the test code out of the production code base.
在示例中Logger,引入一个ILogger接口并创建它的两个实现:一个用于生产的真实实现和一个用于测试目的的假实现。之后,重新定位Controller以接受接口而不是具体类,如以下清单所示。
In the example with Logger, introduce an ILogger interface and create two implementations of it: a real one for production and a fake one for testing purposes. After that, re-target Controller to accept the interface instead of the concrete class, as shown in the following listing.
公共接口ILogger
{
无效日志(字符串文本);
}
public class Logger : ILogger 1
{ 1
public void Log(string text) 1
{ 1
/* 记录文本 */ 1
} 1
} 1
public class FakeLogger : ILogger 2
{ 2
public void Log(string text) 2
{ 2
/* 不执行任何操作 */ 2
} 2
} 2
公共类控制器
{
公共无效SomeMethod(ILogger记录器)
{
logger.Log("某个方法被调用");
}
}public interface ILogger
{
void Log(string text);
}
public class Logger : ILogger 1
{ 1
public void Log(string text) 1
{ 1
/* Log the text */ 1
} 1
} 1
public class FakeLogger : ILogger 2
{ 2
public void Log(string text) 2
{ 2
/* Do nothing */ 2
} 2
} 2
public class Controller
{
public void SomeMethod(ILogger logger)
{
logger.Log("SomeMethod is called");
}
}
这种分离有助于保持生产记录器的简单性,因为它不再需要考虑不同的环境。请注意,它ILogger本身可以说是一种代码污染:它驻留在生产代码库中,但仅用于测试。那么新的实现如何更好呢?
Such a separation helps keep the production logger simple because it no longer has to account for different environments. Note that ILogger itself is arguably a form of code pollution: it resides in the production code base but is only needed for testing. So how is the new implementation better?
这种污染ILogger造成的危害较小,也更容易处理。与最初的Logger实现不同,在新版本中,您不会意外调用不打算用于生产的代码路径。您也不会在接口中出现错误,因为它们只是没有代码的契约。与布尔开关相比,接口不会为潜在错误引入额外的表面积。
The kind of pollution ILogger introduces is less damaging and easier to deal with. Unlike the initial Logger implementation, with the new version, you can’t accidentally invoke a code path that isn’t intended for production use. You can’t have bugs in interfaces, either, because they are just contracts with no code in them. In contrast to Boolean switches, interfaces don’t introduce additional surface area for potential bugs.
到目前为止,本书已经展示了使用接口的模拟示例,但还有另一种方法:您可以模拟具体类,从而保留原始类的部分功能,这有时很有用。但是,这种替代方案有一个重大缺点:它违反了单一责任原则。下一个清单说明了这个想法。
So far, this book has shown mocking examples using interfaces, but there’s an alternative approach: you can mock concrete classes instead and thus preserve part of the original classes’ functionality, which can be useful at times. This alternative has a significant drawback, though: it violates the Single Responsibility principle. The next listing illustrates this idea.
公共类统计计算器
{
公共(双总重量,双总成本)计算(
int 客户编号)
{
列表 <DeliveryRecord> 记录 = GetDeliveries(customerId);
双精度总重量 = 记录数.和(x => x.重量);
双精度总成本 = 记录.总和(x => x.成本);
返回(总重量,总成本);
}
公共列表<DeliveryRecord> GetDeliveries(int customerId)
{
/* 调用进程外依赖项
获取送货清单 */
}
}public class StatisticsCalculator
{
public (double totalWeight, double totalCost) Calculate(
int customerId)
{
List<DeliveryRecord> records = GetDeliveries(customerId);
double totalWeight = records.Sum(x => x.Weight);
double totalCost = records.Sum(x => x.Cost);
return (totalWeight, totalCost);
}
public List<DeliveryRecord> GetDeliveries(int customerId)
{
/* Call an out-of-process dependency
to get the list of deliveries */
}
}
StatisticsCalculator收集并计算客户统计数据:发送给特定客户的所有快递的重量和成本。该类根据从外部服务 (方法) 检索到的快递列表进行计算GetDeliveries。我们还假设有一个使用 的控制器StatisticsCalculator,如以下清单所示。
StatisticsCalculator gathers and calculates customer statistics: the weight and cost of all deliveries sent to a particular customer. The class does the calculation based on the list of deliveries retrieved from an external service (the GetDeliveries method). Let’s also say there’s a controller that uses StatisticsCalculator, as shown in the following listing.
公共类客户控制器
{
私有只读统计计算器_计算器;
公共客户控制器(统计计算器计算器)
{
_calculator = 计算器;
}
公共字符串获取统计信息(int customerId)
{
(双倍总重量,双倍总成本)= _计算器
.计算(客户ID);
返回
$“运送总重量:{totalWeight}。” +
$"总成本:{totalCost}";
}
}public class CustomerController
{
private readonly StatisticsCalculator _calculator;
public CustomerController(StatisticsCalculator calculator)
{
_calculator = calculator;
}
public string GetStatistics(int customerId)
{
(double totalWeight, double totalCost) = _calculator
.Calculate(customerId);
return
$"Total weight delivered: {totalWeight}. " +
$"Total cost: {totalCost}";
}
}
您将如何测试此控制器?您无法为其提供真实StatisticsCalculator实例,因为该实例引用了非托管的进程外依赖项。非托管依赖项必须用存根替换。同时,您也不想StatisticsCalculator完全替换。此类包含重要的计算功能,需要保持完整。
How would you test this controller? You can’t supply it with a real StatisticsCalculator instance, because that instance refers to an unmanaged out-of-process dependency. The unmanaged dependency has to be substituted with a stub. At the same time, you don’t want to replace StatisticsCalculator entirely, either. This class contains important calculation functionality, which needs to be left intact.
解决这一困境的一种方法是模拟类StatisticsCalculator并仅重写GetDeliveries()方法,这可以通过使该方法虚拟化来实现,如下面的清单所示。
One way to overcome this dilemma is to mock the StatisticsCalculator class and override only the GetDeliveries() method, which can be done by making that method virtual, as shown in the following listing.
[事实]
公共无效Customer_with_no_deliveries()
{
// 安排
var stub = new Mock <StatisticsCalculator> { CallBase = true };
stub.Setup(x => x.GetDeliveries(1)) 1
.返回(新 List <DeliveryRecord> ());
var sut = new CustomerController(stub.Object);
// 行为
字符串结果 = sut.GetStatistics(1);
// 断言
Assert.Equal("运送总重量:0。总费用:0", result);
}[Fact]
public void Customer_with_no_deliveries()
{
// Arrange
var stub = new Mock<StatisticsCalculator> { CallBase = true };
stub.Setup(x => x.GetDeliveries(1)) 1
.Returns(new List<DeliveryRecord>());
var sut = new CustomerController(stub.Object);
// Act
string result = sut.GetStatistics(1);
// Assert
Assert.Equal("Total weight delivered: 0. Total cost: 0", result);
}
该CallBase = true设置告知 mock 保留基类的行为,除非明确覆盖。使用这种方法,您可以只替换类的一部分,同时保持其余部分不变。正如我之前提到的,这是一种反模式。
The CallBase = true setting tells the mock to preserve the base class’s behavior unless it’s explicitly overridden. With this approach, you can substitute only a part of the class while keeping the rest as-is. As I mentioned earlier, this is an anti-pattern.
为了保留部分功能而模拟具体类的必要性是违反单一责任原则的结果。
The necessity to mock a concrete class in order to preserve part of its functionality is a result of violating the Single Responsibility principle.
StatisticsCalculator结合了两个不相关的职责:与非托管依赖项通信和计算统计数据。再看一遍清单 11.11。Calculate()方法是领域逻辑所在。GetDeliveries()只是收集该逻辑的输入。不要模拟StatisticsCalculator,而是将此类拆分为两个,如以下清单所示。
StatisticsCalculator combines two unrelated responsibilities: communicating with the unmanaged dependency and calculating statistics. Look at listing 11.11 again. The Calculate() method is where the domain logic lies. GetDeliveries() just gathers the inputs for that logic. Instead of mocking StatisticsCalculator, split this class in two, as the following listing shows.
公共类 DeliveryGateway:IDeliveryGateway
{
公共列表<DeliveryRecord> GetDeliveries(int customerId)
{
/* 调用进程外依赖项
获取送货清单 */
}
}
公共类统计计算器
{
公共(双总重量,双总成本)计算(
列出 <DeliveryRecord> 记录)
{
双精度总重量 = 记录数.和(x => x.重量);
双精度总成本 = 记录.总和(x => x.成本);
返回(总重量,总成本);
}
}public class DeliveryGateway : IDeliveryGateway
{
public List<DeliveryRecord> GetDeliveries(int customerId)
{
/* Call an out-of-process dependency
to get the list of deliveries */
}
}
public class StatisticsCalculator
{
public (double totalWeight, double totalCost) Calculate(
List<DeliveryRecord> records)
{
double totalWeight = records.Sum(x => x.Weight);
double totalCost = records.Sum(x => x.Cost);
return (totalWeight, totalCost);
}
}
下一个清单显示重构后的控制器。
The next listing shows the controller after the refactoring.
公共类客户控制器
{
私有只读统计计算器_计算器;
私人只读IDeliveryGateway _gateway;
公共客户控制器(
StatisticsCalculator 计算器, 1
IDeliveryGateway 网关) 1
{
_calculator = 计算器;
_gateway = 网关;
}
公共字符串获取统计信息(int customerId)
{
var 记录 = _gateway.GetDeliveries(customerId);
(双倍总重量,双倍总成本)= _计算器
.计算(记录);
返回
$“运送总重量:{totalWeight}。” +
$"总成本:{totalCost}";
}
}public class CustomerController
{
private readonly StatisticsCalculator _calculator;
private readonly IDeliveryGateway _gateway;
public CustomerController(
StatisticsCalculator calculator, 1
IDeliveryGateway gateway) 1
{
_calculator = calculator;
_gateway = gateway;
}
public string GetStatistics(int customerId)
{
var records = _gateway.GetDeliveries(customerId);
(double totalWeight, double totalCost) = _calculator
.Calculate(records);
return
$"Total weight delivered: {totalWeight}. " +
$"Total cost: {totalCost}";
}
}
与非托管依赖项进行通信的责任已转移到DeliveryGateway。请注意此网关如何由接口支持,您现在可以使用该接口而不是具体类进行模拟。清单 11.15中的代码是 Humble Object 设计模式的一个实际示例。请参阅第 7 章以了解有关此模式的更多信息。
The responsibility of communicating with the unmanaged dependency has transitioned to DeliveryGateway. Notice how this gateway is backed by an interface, which you can now use for mocking instead of the concrete class. The code in listing 11.15 is an example of the Humble Object design pattern in action. Refer to chapter 7 to learn more about this pattern.
许多应用程序功能需要访问当前日期和时间。但是,测试依赖于时间的功能可能会导致误报:操作阶段的时间可能与断言中的时间不同。有三个选项可以稳定这种依赖关系。其中一个选项是反模式;而其他两个选项中,一个比另一个更可取。
Many application features require access to the current date and time. Testing functionality that depends on time can result in false positives, though: the time during the act phase might not be the same as in the assert. There are three options for stabilizing this dependency. One of these options is an anti-pattern; and of the other two, one is preferable to the other.
第一个选项是使用环境上下文模式。您已经在第 8 章关于测试记录器的部分中看到了这种模式。在时间上下文中,环境上下文将是一个自定义类,您可以在代码中使用它,而不是框架的内置类DateTime.Now,如下一个清单所示。
The first option is to use the ambient context pattern. You already saw this pattern in chapter 8 in the section about testing loggers. In the context of time, the ambient context would be a custom class that you’d use in code instead of the framework’s built-in DateTime.Now, as shown in the next listing.
公共静态类 DateTimeServer
{
私有静态 Func<DateTime> _func;
公共静态DateTime Now => _func();
公共静态无效Init(Func <DateTime> func)
{
_func = 函数;
}
}
DateTimeServer.Init(() => DateTime.Now); 1
DateTimeServer.Init(() => new DateTime(2020, 1, 1)); 2public static class DateTimeServer
{
private static Func<DateTime> _func;
public static DateTime Now => _func();
public static void Init(Func<DateTime> func)
{
_func = func;
}
}
DateTimeServer.Init(() => DateTime.Now); 1
DateTimeServer.Init(() => new DateTime(2020, 1, 1)); 2
就像记录器功能一样,使用环境上下文来记录时间也是一种反模式。环境上下文会污染生产代码并使测试更加困难。此外,静态字段会引入测试之间共享的依赖关系,从而将这些测试转移到集成测试领域。
Just as with the logger functionality, using an ambient context for time is also an anti-pattern. The ambient context pollutes the production code and makes testing more difficult. Also, the static field introduces a dependency shared between tests, thus transitioning those tests into the sphere of integration testing.
更好的方法是明确注入时间依赖项(而不是通过环境上下文中的静态方法引用它),作为服务或普通值,如下面的清单所示。
A better approach is to inject the time dependency explicitly (instead of referring to it via a static method in an ambient context), either as a service or as a plain value, as shown in the following listing.
公共接口IDateTimeServer
{
日期时间现在 { 获取; }
}
公共类 DateTimeServer:IDateTimeServer
{
公共 DateTime Now => DateTime.Now;
}
公共类InquiryController
{
私有只读IDateTimeServer _dateTimeServer;
公共查询控制器(
IDateTimeServer 日期时间服务器) 1
{
_dateTime服务器 = 日期时间服务器;
}
公共无效ApproveInquiry(int id)
{
询价inquiry = GetById(id);
询价.批准(_dateTimeServer.Now); 2
保存查询(查询);
}
}public interface IDateTimeServer
{
DateTime Now { get; }
}
public class DateTimeServer : IDateTimeServer
{
public DateTime Now => DateTime.Now;
}
public class InquiryController
{
private readonly IDateTimeServer _dateTimeServer;
public InquiryController(
IDateTimeServer dateTimeServer) 1
{
_dateTimeServer = dateTimeServer;
}
public void ApproveInquiry(int id)
{
Inquiry inquiry = GetById(id);
inquiry.Approve(_dateTimeServer.Now); 2
SaveInquiry(inquiry);
}
}
在这两个选项中,最好将时间作为值而不是服务注入。在生产代码中使用纯值更容易,在测试中存根这些值也更容易。
Of these two options, prefer injecting the time as a value rather than as a service. It’s easier to work with plain values in production code, and it’s also easier to stub those values in tests.
最有可能的是,您无法始终将时间作为纯值注入,因为依赖项注入框架无法很好地处理值对象。一个好的折衷方案是在业务操作开始时将时间作为服务注入,然后在该操作的其余部分将其作为值传递。您可以在清单 11.17中看到这种方法:控制器接受DateTimeServer(服务),然后将DateTime值传递给Inquiry域类。
Most likely, you won’t be able to always inject the time as a plain value, because dependency injection frameworks don’t play well with value objects. A good compromise is to inject the time as a service at the start of a business operation and then pass it as a value in the remainder of that operation. You can see this approach in listing 11.17: the controller accepts DateTimeServer (the service) but then passes a DateTime value to the Inquiry domain class.
在本章中,我们研究了一些最突出的真实世界单元测试用例,并使用优秀测试的四个属性对它们进行了分析。 我明白,一次性应用本书中的所有想法和指南可能会让人不知所措。而且,你的情况可能没有那么明确。我会在我的博客https://enterprisecraftsmanship.com上发布对其他人代码的评论并回答问题(通常与单元测试和代码设计相关)。你也可以在https://enterprisecraftsmanship.com/about提交自己的问题。你可能还对参加我的在线课程感兴趣,我将在其中展示如何从头开始构建应用程序,并在实践中应用本书中描述的所有原则,课程地址为https://unittestingcourse.com。
In this chapter, we looked at some of the most prominent real-world unit testing use cases and analyzed them using the four attributes of a good test. I understand that it may be overwhelming to start applying all the ideas and guidelines from this book at once. Also, your situation might not be as clear-cut. I publish reviews of other people’s code and answer questions (related to unit testing and code design in general) on my blog at https://enterprisecraftsmanship.com. You can also submit your own question at https://enterprisecraftsmanship.com/about. You might also be interested in taking my online course, where I show how to build an application from the ground up, applying all the principles described in this book in practice, at https://unittestingcourse.com.
您可以随时在 Twitter 上关注我 @vkhorikov,或者直接通过https://enterprisecraftsmanship.com/about与我联系。我期待收到您的来信!
You can always catch me on twitter at @vkhorikov, or contact me directly through https://enterprisecraftsmanship.com/about. I look forward to hearing from you!
[ A ][ B ][ C ][ D ][ E ][ F ][ G ][ H ][ I ][ J ][ L ][ M ][ N ][ O ][ P ][ Q ][ R ][ S ][ T ][ U ][ V ][ W ][ X ][ Y ]
[A][B][C][D][E][F][G][H][I][J][L][M][N][O][P][Q][R][S][T][U][V][W][X][Y]
AAA (arrange, act, and assert) pattern
avoiding if statements
avoiding multiple AAA sections
differentiating system under test
dropping AAA comments
overview
reusing code in test sections
in act sections
in arrange sections
in assert sections
section size
arrange section
number of assertions in assert section
sections larger than a single line
teardown phase
abstractions, 2nd
Active Record pattern
adapters
aggregates
ambient context
anti-patterns
code pollution
exposing private state
leaking domain knowledge to tests
mocking concrete classes
private methods
acceptability of testing
insufficient coverage
test fragility
time
as ambient context
as explicit dependency
API (application programming interface), 2nd, 3rd, 4th, 5th, 6th, 7th
missing abstractions
public vs. private
well-designed, 2nd, 3rd, 4th
application behavior
application services layer
arrange, act, and assert pattern.
See AAA pattern.
assertion libraries, using to improve test readability
assertion-free testing
asynchronous communications
atomic updates
automation concepts
black-box vs. white-box testing
Test Pyramid
AAA (arrange, act, and assert) pattern
avoiding if statements
avoiding multiple AAA sections
differentiating system under test
dropping AAA comments
overview
reusing code in test sections
in act sections
in arrange sections
in assert sections
section size
arrange section
number of assertions in assert section
sections larger than a single line
teardown phase
abstractions, 2nd
Active Record pattern
adapters
aggregates
ambient context
anti-patterns
code pollution
exposing private state
leaking domain knowledge to tests
mocking concrete classes
private methods
acceptability of testing
insufficient coverage
test fragility
time
as ambient context
as explicit dependency
API (application programming interface), 2nd, 3rd, 4th, 5th, 6th, 7th
missing abstractions
public vs. private
well-designed, 2nd, 3rd, 4th
application behavior
application services layer
arrange, act, and assert pattern.
See AAA pattern.
assertion libraries, using to improve test readability
assertion-free testing
asynchronous communications
atomic updates
automation concepts
black-box vs. white-box testing
Test Pyramid
backward migration
bad tests
black-box testing, 2nd
Boolean switches
branch coverage metric
brittle tests, 2nd, 3rd
brittleness, 2nd
bugs, 2nd, 3rd, 4th, 5th
business logic, 2nd, 3rd, 4th
backward migration
bad tests
black-box testing, 2nd
Boolean switches
branch coverage metric
brittle tests, 2nd, 3rd
brittleness, 2nd
bugs, 2nd, 3rd, 4th, 5th
business logic, 2nd, 3rd, 4th
CanExecute/Execute pattern, 2nd
CAP theorem
captured data
circular dependencies
defined
eliminating
classical school of unit testing
dependencies
end-to-end tests
integration tests
isolation issue
mocks
mocking out out-of-process dependencies
using mocks to verify behavior
precise bug location
testing large graph of interconnected classes
testing one class at a time
cleanup phase
clusters, grouping into aggregates
code complexity, 2nd
code coverage metric
code coverage tools
code depth
code pollution, 2nd, 3rd
code width
collaborators, 2nd, 3rd
command query separation.
See CQS principle.
commands
communication-based testing, 2nd
feedback speed
maintainability
overuse of
protection against regressions and feedback speed
resistance to refactoring
vulnerability to false alarms
communications
between applications, 2nd
between classes in application, 2nd
conditional logic
CanExecute/Execute pattern
domain events for tracking changes in the domain model
constructors, reusing test fixtures between tests
containers
controllers, 2nd
simplicity
coverage metrics, measuring test suite quality with
aiming for particular coverage number
branch coverage metric
code coverage metric
problems with
code paths in external libraries
impossible to verify all possible outcomes
CQS (command query separation) principle
CRUD (create, read, update, and delete) operations
CSV files
cyclic dependency
cyclomatic complexity
CanExecute/Execute pattern, 2nd
CAP theorem
captured data
circular dependencies
defined
eliminating
classical school of unit testing
dependencies
end-to-end tests
integration tests
isolation issue
mocks
mocking out out-of-process dependencies
using mocks to verify behavior
precise bug location
testing large graph of interconnected classes
testing one class at a time
cleanup phase
clusters, grouping into aggregates
code complexity, 2nd
code coverage metric
code coverage tools
code depth
code pollution, 2nd, 3rd
code width
collaborators, 2nd, 3rd
command query separation.
See CQS principle.
commands
communication-based testing, 2nd
feedback speed
maintainability
overuse of
protection against regressions and feedback speed
resistance to refactoring
vulnerability to false alarms
communications
between applications, 2nd
between classes in application, 2nd
conditional logic
CanExecute/Execute pattern
domain events for tracking changes in the domain model
constructors, reusing test fixtures between tests
containers
controllers, 2nd
simplicity
coverage metrics, measuring test suite quality with
aiming for particular coverage number
branch coverage metric
code coverage metric
problems with
code paths in external libraries
impossible to verify all possible outcomes
CQS (command query separation) principle
CRUD (create, read, update, and delete) operations
CSV files
cyclic dependency
cyclomatic complexity
data inconsistencies
data mapping
data motion
data, bundling
database backup, restoring
database management system (DBMS)
database testing
common questions
testing reads
testing repositories
database transaction management
in integration tests
in production code
prerequisites for
keeping database in source control system
reference data as part of database schema
separate instances for every developer
state-based vs. migration-based database delivery
reusing code in test sections
creating too many database transactions
in act sections
in arrange sections
in assert sections
test data life cycle
avoiding in-memory databases
clearing data between test runs
parallel vs. sequential test execution
database transaction management
in integration tests
in production code
separating connections from transactions
upgrading transaction to unit of work
database transactions
daysFromNow parameter
DBMS (database management system)
dead code
deliveryDate parameter
dependencies, 2nd
classical school of unit testing
London school of unit testing
out-of-process, 2nd
shared, 2nd
types of
Detroit approach, unit testing
diagnostic logging, 2nd
discovered abstractions
Docker container
domain events, tracking changes in domain model
domain layers, 2nd, 3rd
domain model, 2nd, 3rd
connecting with external applications
testability
domain significance
dummy test double
data inconsistencies
data mapping
data motion
data, bundling
database backup, restoring
database management system (DBMS)
database testing
common questions
testing reads
testing repositories
database transaction management
in integration tests
in production code
prerequisites for
keeping database in source control system
reference data as part of database schema
separate instances for every developer
state-based vs. migration-based database delivery
reusing code in test sections
creating too many database transactions
in act sections
in arrange sections
in assert sections
test data life cycle
avoiding in-memory databases
clearing data between test runs
parallel vs. sequential test execution
database transaction management
in integration tests
in production code
separating connections from transactions
upgrading transaction to unit of work
database transactions
daysFromNow parameter
DBMS (database management system)
dead code
deliveryDate parameter
dependencies, 2nd
classical school of unit testing
London school of unit testing
out-of-process, 2nd
shared, 2nd
types of
Detroit approach, unit testing
diagnostic logging, 2nd
discovered abstractions
Docker container
domain events, tracking changes in domain model
domain layers, 2nd, 3rd
domain model, 2nd, 3rd
connecting with external applications
testability
domain significance
dummy test double
EasyMock
edge cases, 2nd, 3rd
encapsulation, 2nd
end-to-end tests, 2nd, 3rd, 4th
classical school of unit testing
London school of unit testing
possibility of creating ideal tests
enterprise applications
Entity Framework, 2nd
entropy
error handling
exceptions
expected parameter
explicit inputs and outputs
external libraries
external reads, 2nd
external state
external writes, 2nd
EasyMock
edge cases, 2nd, 3rd
encapsulation, 2nd
end-to-end tests, 2nd, 3rd, 4th
classical school of unit testing
London school of unit testing
possibility of creating ideal tests
enterprise applications
Entity Framework, 2nd
entropy
error handling
exceptions
expected parameter
explicit inputs and outputs
external libraries
external reads, 2nd
external state
external writes, 2nd
Fail Fast principle, 2nd
failing preconditions
fake dependencies
fake test double
false negatives
false positives, 2nd, 3rd, 4th, 5th, 6th, 7th
causes of
importance of
fast feedback, 2nd, 3rd, 4th, 5th, 6th
fat controllers
feedback loop, shortening
feedback speed, 2nd
fixed state
Fluent Assertions
fragile tests, 2nd
frameworks
functional architecture
defined
drawbacks of
applicability of
code base size increases
performance drawbacks
functional programming
hexagonal architecture
transitioning to output-based testing
audit system
refactoring toward functional architecture
using mocks to decouple tests from filesystem
functional core, 2nd, 3rd
functional programming
functional testing, 2nd, 3rd
Fail Fast principle, 2nd
failing preconditions
fake dependencies
fake test double
false negatives
false positives, 2nd, 3rd, 4th, 5th, 6th, 7th
causes of
importance of
fast feedback, 2nd, 3rd, 4th, 5th, 6th
fat controllers
feedback loop, shortening
feedback speed, 2nd
fixed state
Fluent Assertions
fragile tests, 2nd
frameworks
functional architecture
defined
drawbacks of
applicability of
code base size increases
performance drawbacks
functional programming
hexagonal architecture
transitioning to output-based testing
audit system
refactoring toward functional architecture
using mocks to decouple tests from filesystem
functional core, 2nd, 3rd
functional programming
functional testing, 2nd, 3rd
Git
Given-When-Then pattern
GUI (graphical user interface) tests
Git
Given-When-Then pattern
GUI (graphical user interface) tests
handwritten mocks, 2nd
happy paths, 2nd, 3rd
helper methods
hexagonal architecture, 2nd, 3rd
defining
functional architecture
purpose of
hexagons, 2nd, 3rd
hidden outputs
high coupling, reusing test fixtures between tests
HTML tags
humble controller
Humble Object pattern, 2nd, 3rd, 4th
humble objects
humble wrappers
handwritten mocks, 2nd
happy paths, 2nd, 3rd
helper methods
hexagonal architecture, 2nd, 3rd
defining
functional architecture
purpose of
hexagons, 2nd, 3rd
hidden outputs
high coupling, reusing test fixtures between tests
HTML tags
humble controller
Humble Object pattern, 2nd, 3rd, 4th
humble objects
humble wrappers
ideal tests
brittle tests
end-to-end tests
possibility of creating
trivial tests
if statements, 2nd, 3rd, 4th, 5th
immutability
immutable classes
immutable core, 2nd
immutable events
immutable objects, 2nd
implementation details
incoming interactions
infrastructure code
infrastructure layer
in-memory databases
in-process dependencies
INSERT statements
integer type
integration testing
best practices
eliminating circular dependencies
making domain model boundaries explicit
multiple act sections
reducing number of layers
classical school of unit testing
database transaction management in
defined
example of
categorizing database and message bus
end-to-end testing
first version
scenarios
failing fast
interfaces for abstracting dependencies
in-process dependencies
loose coupling and
out-of-process dependencies
logging functionality
amount of logging
introducing wrapper on top of ILogger
passing around logger instances
structured logging
whether to test or not
writing tests for support and diagnostic logging
London school of unit testing
out-of-process dependencies
types of
when real databases are unavailable
working with both
role of
Test Pyramid
interconnected classes
internal keyword
invariant violations, 2nd
invariants, 2nd
isolation issue
classical school of unit testing
London school of unit testing
isSuccess flag
ideal tests
brittle tests
end-to-end tests
possibility of creating
trivial tests
if statements, 2nd, 3rd, 4th, 5th
immutability
immutable classes
immutable core, 2nd
immutable events
immutable objects, 2nd
implementation details
incoming interactions
infrastructure code
infrastructure layer
in-memory databases
in-process dependencies
INSERT statements
integer type
integration testing
best practices
eliminating circular dependencies
making domain model boundaries explicit
multiple act sections
reducing number of layers
classical school of unit testing
database transaction management in
defined
example of
categorizing database and message bus
end-to-end testing
first version
scenarios
failing fast
interfaces for abstracting dependencies
in-process dependencies
loose coupling and
out-of-process dependencies
logging functionality
amount of logging
introducing wrapper on top of ILogger
passing around logger instances
structured logging
whether to test or not
writing tests for support and diagnostic logging
London school of unit testing
out-of-process dependencies
types of
when real databases are unavailable
working with both
role of
Test Pyramid
interconnected classes
internal keyword
invariant violations, 2nd
invariants, 2nd
isolation issue
classical school of unit testing
London school of unit testing
isSuccess flag
logging functionality testing
amount of logging
introducing wrapper on top of ILogger
passing around logger instances
structured logging
whether to test or not
writing tests for support and diagnostic logging
London school of unit testing
dependencies
end-to-end tests
integration tests
isolation issue
mocks
mocking out out-of-process dependencies
using mocks to verify behavior
precise bug location
testing large graph of interconnected classes
testing one class at a time
loose coupling, interfaces for abstracting dependencies and
logging functionality testing
amount of logging
introducing wrapper on top of ILogger
passing around logger instances
structured logging
whether to test or not
writing tests for support and diagnostic logging
London school of unit testing
dependencies
end-to-end tests
integration tests
isolation issue
mocks
mocking out out-of-process dependencies
using mocks to verify behavior
precise bug location
testing large graph of interconnected classes
testing one class at a time
loose coupling, interfaces for abstracting dependencies and
maintainability, 2nd, 3rd, 4th, 5th, 6th, 7th, 8th
comparing testing styles
communication-based tests
output-based tests
state-based tests
managed dependencies, 2nd, 3rd
mathematical functions
merging domain events
message bus, 2nd, 3rd, 4th
method signatures
method under test (MUT)
Microsoft MSTest
migration-based database delivery
missing abstractions
mock chains
mocking frameworks
mockist style, unit testing
Mockito
mocks, 2nd
best practices
for integration tests only
not just one mock per test
only mock types that you own
verifying number of calls
decoupling tests from filesystem
defined
London school vs. classical school
mocking out out-of-process dependencies
using mocks to verify behavior
maximizing value of
IDomainLogger
replacing mocks with spies
verifying interactions at system edges
mocking concrete classes
observable behavior vs. implementation details
leaking implementation details
observable behavior vs. public API
well-designed API and encapsulation
stubs
asserting interactions with stubs
commands and queries
mock (tool) vs. mock (test double)
types of test doubles
using mocks and stubs together
test doubles
test fragility
defining hexagonal architecture
intra-system vs. inter-system communications
model database
Model-View-Controller (MVC) pattern
Moq, 2nd, 3rd
MSTest
MUT (method under test)
mutable objects
mutable shell, 2nd
MVC (Model-View-Controller) pattern
maintainability, 2nd, 3rd, 4th, 5th, 6th, 7th, 8th
comparing testing styles
communication-based tests
output-based tests
state-based tests
managed dependencies, 2nd, 3rd
mathematical functions
merging domain events
message bus, 2nd, 3rd, 4th
method signatures
method under test (MUT)
Microsoft MSTest
migration-based database delivery
missing abstractions
mock chains
mocking frameworks
mockist style, unit testing
Mockito
mocks, 2nd
best practices
for integration tests only
not just one mock per test
only mock types that you own
verifying number of calls
decoupling tests from filesystem
defined
London school vs. classical school
mocking out out-of-process dependencies
using mocks to verify behavior
maximizing value of
IDomainLogger
replacing mocks with spies
verifying interactions at system edges
mocking concrete classes
observable behavior vs. implementation details
leaking implementation details
observable behavior vs. public API
well-designed API and encapsulation
stubs
asserting interactions with stubs
commands and queries
mock (tool) vs. mock (test double)
types of test doubles
using mocks and stubs together
test doubles
test fragility
defining hexagonal architecture
intra-system vs. inter-system communications
model database
Model-View-Controller (MVC) pattern
Moq, 2nd, 3rd
MSTest
MUT (method under test)
mutable objects
mutable shell, 2nd
MVC (Model-View-Controller) pattern
naming tests
guidelines for
renaming tests to meet guidelines
NHibernate
noise, reducing
NSubstitute
NuGet package
NUnit, 2nd
naming tests
guidelines for
renaming tests to meet guidelines
NHibernate
noise, reducing
NSubstitute
NuGet package
NUnit, 2nd
object graphs
Object Mother
object-oriented programming (OOP), 2nd
object-relational mapping (ORM), 2nd, 3rd, 4th, 5th, 6th, 7th
observable behavior, 2nd, 3rd, 4th, 5th
leaking implementation details
public API
well-designed API and encapsulation
OCP (Open-Closed principle)
OOP (object-oriented programming), 2nd
Open-Closed principle (OCP)
operations, 2nd
orchestration, separating business logic from, 2nd
ORM (object-relational mapping), 2nd, 3rd, 4th, 5th, 6th, 7th
outcoming interactions
out-of-process collaborators
out-of-process dependencies, 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, 9th, 10th, 11th, 12th, 13th
integration testing
interfaces for abstracting dependencies
types of
when real databases are unavailable
working with both
output value
output-based testing, 2nd, 3rd
feedback speed
maintainability
protection against regressions and feedback speed
resistance to refactoring
transitioning to functional architecture and
audit system
refactoring toward functional architecture
using mocks to decouple tests from filesystem
overcomplicated code
overspecification
object graphs
Object Mother
object-oriented programming (OOP), 2nd
object-relational mapping (ORM), 2nd, 3rd, 4th, 5th, 6th, 7th
observable behavior, 2nd, 3rd, 4th, 5th
leaking implementation details
public API
well-designed API and encapsulation
OCP (Open-Closed principle)
OOP (object-oriented programming), 2nd
Open-Closed principle (OCP)
operations, 2nd
orchestration, separating business logic from, 2nd
ORM (object-relational mapping), 2nd, 3rd, 4th, 5th, 6th, 7th
outcoming interactions
out-of-process collaborators
out-of-process dependencies, 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, 9th, 10th, 11th, 12th, 13th
integration testing
interfaces for abstracting dependencies
types of
when real databases are unavailable
working with both
output value
output-based testing, 2nd, 3rd
feedback speed
maintainability
protection against regressions and feedback speed
resistance to refactoring
transitioning to functional architecture and
audit system
refactoring toward functional architecture
using mocks to decouple tests from filesystem
overcomplicated code
overspecification
parallel test execution
parameterized tests, 2nd
partition tolerance
performance
persistence state
preconditions
private APIs
private constructors
private dependencies, 2nd, 3rd
private keyword
private methods
acceptability of testing
insufficient coverage and
reusing test fixtures between tests
test fragility and
Product array
production code
protection against regressions, 2nd, 3rd, 4th, 5th, 6th
comparing testing styles
importance of false positives and false negatives
maximizing test accuracy
Public API, 2nd
pure functions
parallel test execution
parameterized tests, 2nd
partition tolerance
performance
persistence state
preconditions
private APIs
private constructors
private dependencies, 2nd, 3rd
private keyword
private methods
acceptability of testing
insufficient coverage and
reusing test fixtures between tests
test fragility and
Product array
production code
protection against regressions, 2nd, 3rd, 4th, 5th, 6th
comparing testing styles
importance of false positives and false negatives
maximizing test accuracy
Public API, 2nd
pure functions
random number generators
read operations
readability
read-decide-act approach
refactoring
analysis of optimal test coverage
testing domain layer and utility code
testing from other three quadrants
testing preconditions
conditional logic in controllers
CanExecute/Execute pattern
domain events for tracking changes in the domain model
identifying code to refactor
four types of code
Humble Object pattern for splitting overcomplicated code
resistance to
comparing testing styles
importance of false positives and false negatives
maximizing test accuracy
to parameterized tests
general discussion
generating data for parameterized tests
toward valuable unit tests
application services layer
Company class
customer management system
making implicit dependencies explicit
removing complexity from application service
reference data, 2nd, 3rd
referential transparency
regression errors, 2nd, 3rd
regressions, 2nd
repositories, 2nd, 3rd
resistance to refactoring, 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, 9th
comparing testing styles
importance of false positives and false negatives
maximizing test accuracy
return statement
return true statement
reusability
random number generators
read operations
readability
read-decide-act approach
refactoring
analysis of optimal test coverage
testing domain layer and utility code
testing from other three quadrants
testing preconditions
conditional logic in controllers
CanExecute/Execute pattern
domain events for tracking changes in the domain model
identifying code to refactor
four types of code
Humble Object pattern for splitting overcomplicated code
resistance to
comparing testing styles
importance of false positives and false negatives
maximizing test accuracy
to parameterized tests
general discussion
generating data for parameterized tests
toward valuable unit tests
application services layer
Company class
customer management system
making implicit dependencies explicit
removing complexity from application service
reference data, 2nd, 3rd
referential transparency
regression errors, 2nd, 3rd
regressions, 2nd
repositories, 2nd, 3rd
resistance to refactoring, 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, 9th
comparing testing styles
importance of false positives and false negatives
maximizing test accuracy
return statement
return true statement
reusability
scalability
sequential test execution
shallowness
shared dependencies, 2nd, 3rd, 4th, 5th, 6th
side effects, 2nd
signal-to-noise ratio
Single Responsibility principle, 2nd, 3rd
single-line act section
SMTP service, 2nd, 3rd, 4th
software bugs, 2nd
software entropy
source of truth
spies, 2nd
spy test double
SQL scripts, 2nd, 3rd
SQLite
state, 2nd
state verification
state-based database delivery
state-based testing, 2nd, 3rd, 4th
feedback speed
maintainability
protection against regressions and feedback speed
resistance to refactoring
stubs, mocks
asserting interactions with stubs
commands and queries
mock (tool) vs. mock (test double)
types of test doubles
using mocks and stubs together
sub-renderers collection
support logging, 2nd
sustainability
sustainable growth
SUT (system under test), 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, 9th, 10th, 11th, 12th, 13th, 14th, 15th, 16th, 17th, 18th
switch statement
synchronous communications
system leaks
scalability
sequential test execution
shallowness
shared dependencies, 2nd, 3rd, 4th, 5th, 6th
side effects, 2nd
signal-to-noise ratio
Single Responsibility principle, 2nd, 3rd
single-line act section
SMTP service, 2nd, 3rd, 4th
software bugs, 2nd
software entropy
source of truth
spies, 2nd
spy test double
SQL scripts, 2nd, 3rd
SQLite
state, 2nd
state verification
state-based database delivery
state-based testing, 2nd, 3rd, 4th
feedback speed
maintainability
protection against regressions and feedback speed
resistance to refactoring
stubs, mocks
asserting interactions with stubs
commands and queries
mock (tool) vs. mock (test double)
types of test doubles
using mocks and stubs together
sub-renderers collection
support logging, 2nd
sustainability
sustainable growth
SUT (system under test), 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, 9th, 10th, 11th, 12th, 13th, 14th, 15th, 16th, 17th, 18th
switch statement
synchronous communications
system leaks
tables
tautology tests
TDD (test-driven development), 2nd
tell-don’t-ask principle
test code
test coverage
Test Data Builder
test data life cycle
avoiding in-memory databases
clearing data between test runs
parallel vs. sequential test execution
test doubles, 2nd, 3rd, 4th, 5th, 6th
test fixtures
defined
reusing between tests
constructors
high coupling
private factory methods
reusing between tests
test fragility, mocks and
defining hexagonal architecture
intra-system vs. inter-system communications
test isolation
Test Pyramid
general discussion
integration testing
test suites
characteristics of successful suites
integration into development cycle
maximum value with minimum maintenance costs
targeting most important parts of code base
coverage metrics, measuring test suite quality with
aiming for particular coverage number
branch coverage metric
code coverage metric
problems with
third-party applications, 2nd
tight coupling
time
as ambient context
as explicit dependency
trivial code
trivial tests
true negative
true positive
two-line act section
tables
tautology tests
TDD (test-driven development), 2nd
tell-don’t-ask principle
test code
test coverage
Test Data Builder
test data life cycle
avoiding in-memory databases
clearing data between test runs
parallel vs. sequential test execution
test doubles, 2nd, 3rd, 4th, 5th, 6th
test fixtures
defined
reusing between tests
constructors
high coupling
private factory methods
reusing between tests
test fragility, mocks and
defining hexagonal architecture
intra-system vs. inter-system communications
test isolation
Test Pyramid
general discussion
integration testing
test suites
characteristics of successful suites
integration into development cycle
maximum value with minimum maintenance costs
targeting most important parts of code base
coverage metrics, measuring test suite quality with
aiming for particular coverage number
branch coverage metric
code coverage metric
problems with
third-party applications, 2nd
tight coupling
time
as ambient context
as explicit dependency
trivial code
trivial tests
true negative
true positive
two-line act section
UI (user interface) tests
unit of behavior, 2nd
unit of work, 2nd
unit testing
anatomy of
AAA pattern
assertion libraries, using to improve test readability
naming tests
refactoring to parameterized tests
reusing test fixtures between tests
xUnit testing framework
automation concepts
black-box vs. white-box testing
Test Pyramid
characteristics of successful test suites
integration into development cycle
maximum value with minimum maintenance costs
targeting most important parts of code base
classical school of
dependencies
end-to-end tests
integration tests
isolation issue
precise bug location
testing large graph of interconnected classes
testing one class at a time
coverage metrics, measuring test suite quality with
aiming for particular coverage number
branch coverage metric
code coverage metric
problems with
current state of
defined
four pillars of
feedback speed
maintainability
protection against regressions
resistance to refactoring
functional architecture
defined
drawbacks of
functional programming
hexagonal architecture
transitioning to output-based testing
goal of
good vs. bad tests
ideal tests
brittle tests
end-to-end tests
possibility of creating
trivial tests
London school of
dependencies
end-to-end tests
integration tests
isolation issue
precise bug location
testing large graph of interconnected classes
testing one class at a time
styles of
communication-based testing
comparing
output-based testing
state-based testing
units of behavior
units of code, 2nd, 3rd, 4th, 5th
unmanaged dependencies, 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, 9th
user controller
user interface (UI) tests
UI (user interface) tests
unit of behavior, 2nd
unit of work, 2nd
unit testing
anatomy of
AAA pattern
assertion libraries, using to improve test readability
naming tests
refactoring to parameterized tests
reusing test fixtures between tests
xUnit testing framework
automation concepts
black-box vs. white-box testing
Test Pyramid
characteristics of successful test suites
integration into development cycle
maximum value with minimum maintenance costs
targeting most important parts of code base
classical school of
dependencies
end-to-end tests
integration tests
isolation issue
precise bug location
testing large graph of interconnected classes
testing one class at a time
coverage metrics, measuring test suite quality with
aiming for particular coverage number
branch coverage metric
code coverage metric
problems with
current state of
defined
four pillars of
feedback speed
maintainability
protection against regressions
resistance to refactoring
functional architecture
defined
drawbacks of
functional programming
hexagonal architecture
transitioning to output-based testing
goal of
good vs. bad tests
ideal tests
brittle tests
end-to-end tests
possibility of creating
trivial tests
London school of
dependencies
end-to-end tests
integration tests
isolation issue
precise bug location
testing large graph of interconnected classes
testing one class at a time
styles of
communication-based testing
comparing
output-based testing
state-based testing
units of behavior
units of code, 2nd, 3rd, 4th, 5th
unmanaged dependencies, 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, 9th
user controller
user interface (UI) tests
value objects, 2nd
void type
volatile dependencies
value objects, 2nd
void type
volatile dependencies
white-box testing
write operation
white-box testing
write operation
第 1 章 单元测试的目标
Chapter 1. The goal of unit testing
图 1.1. 有测试和无测试的项目之间的增长动态差异。没有测试的项目虽然起步较快,但很快就会放缓,以至于很难取得任何进展。
图 1.2. 测试良好和测试不良的项目之间的增长动态差异。测试编写不良的项目在开始时会表现出测试良好的项目的特性,但最终会陷入停滞阶段。
图 1.3。代码覆盖率(测试覆盖率)指标计算为测试套件执行的代码行数与生产代码库中的总行数之间的比率。
图 1.4. 分支指标的计算方法是测试套件执行的代码分支数与生产代码库中的分支总数之比。
图 1.5。方法 IsStringLong 以可能的代码路径图表示。测试仅覆盖两个代码路径中的其中一个,因此提供 50% 的分支覆盖率。
第 2 章什么是单元测试?
Chapter 2. What is a unit test?
图 2.1. 使用测试替身替换被测系统的依赖关系,使您能够专注于专门验证被测系统,并拆分原本庞大的互连对象图。
图 2.2。将被测试的类与其依赖项隔离有助于建立一个简单的测试套件结构:一个类,其中包含生产代码中每个类的测试。
图 2.3。将单元测试彼此隔离意味着仅将被测试的类与共享依赖项隔离。私有依赖项可以保持完整。
图 2.4 依赖关系的层次结构。古典学派主张用测试替身替代共享依赖关系。伦敦学派也主张替换私有依赖关系,只要它们是可变的。
图 2.6。端到端测试通常包括所有或几乎所有进程外依赖项。集成测试仅检查一两个这样的依赖项 — 那些更容易自动设置的依赖项,例如数据库或文件系统。
第 3 章 单元测试的结构
Chapter 3. The anatomy of a unit test
图 3.1。多个 Arrange、Act 和 Assertion 部分表明测试一次验证了太多内容。需要将此类测试拆分为多个测试来修复此问题。
图 3.2。典型应用程序表现出多种行为。行为越复杂,需要的事实就越多,才能完整描述它。每个事实都由一个测试表示。可以使用参数化测试将类似的事实分组到单个测试方法中。
第 4 章 良好单元测试的四大支柱
Chapter 4. The four pillars of a good unit test
图 4.1。与 SUT 算法耦合的测试。此类测试需要看到一种特定的实现(SUT 必须采取的具体步骤才能提供结果),因此很脆弱。对 SUT 实现的任何重构都会导致测试失败。
图 4.2。左侧的测试与 SUT 的可观察行为(而不是实现细节)相结合。这样的测试可以抵抗重构 — 几乎不会触发误报。
图 4.3。保护回归和抵抗重构之间的关系。保护回归可以防止误报(II 类错误)。抵抗重构可以最大限度地减少误报(I 类错误)的数量。
图 4.4. 只要测试能产生强信号(能够发现错误),且噪音(误报)尽可能少,那么测试就是准确的。
图 4.5。误报(错误警报)一开始不会产生太大的负面影响。但随着项目的发展,它们变得越来越重要——与误报(未被注意到的错误)一样重要。
图 4.6. 端到端测试可以很好地防止回归错误和误报,但它们在快速反馈指标上却失败了。
图 4.7。简单测试对重构有很好的抵抗力,而且它们能提供快速反馈,但是这种测试不能保护你免受回归的影响。
图 4.8。脆弱测试运行速度快,可以很好地防止回归,但对重构的抵抗力很弱。
图 4.9。创建一个在三个属性上都取得满分的理想测试是不可能的。
图 4.10。最佳测试表现出最大的可维护性和抗重构性;始终尝试最大化这两个属性。权衡归结为在防止回归和快速反馈之间做出选择。
图 4.11。测试金字塔主张单元测试、集成测试和端到端测试保持一定的比例。
图 4.12。金字塔中的不同类型的测试在快速反馈和防止回归之间做出不同的选择。端到端测试有利于防止回归,单元测试强调快速反馈,而集成测试则处于中间位置。
第 5 章 Mocks 和测试脆弱性
Chapter 5. Mocks and test fragility
图 5.2。发送电子邮件是一种输出交互:一种会导致 SMTP 服务器产生副作用的交互。模拟此类交互的测试替身是模拟。从数据库检索数据是一种输入交互;它不会导致副作用。相应的测试替身是存根。
图 5.3。在命令查询分离(CQS)原则中,命令与模拟相对应,而查询与存根一致。
图 5.4。在设计良好的 API 中,可观察的行为与公共 API 一致,而所有实现细节都隐藏在私有 API 后面。
图 5.5. 当系统的公共 API 超出可观察行为的范围时,系统会泄露实现细节。
图 5.6。User 的 API 设计得不太好:它公开了 NormalizeName 方法,而这并不是可观察行为的一部分。
图 5.7。具有精心设计的 API 的用户。只有可观察的行为是公开的;实现细节现在是私有的。
图 5.8。典型的应用程序由领域层和应用服务层组成。领域层包含应用程序的业务逻辑;应用服务将该逻辑与业务用例联系起来。
图 5.10。使用不同层的测试具有分形性质:它们在不同级别验证相同的行为。应用服务测试检查整体业务用例的执行情况。使用域类的测试验证用例完成过程中的中间子目标。
图 5.11。有两种类型的通信:系统内(应用程序内部的类之间)和系统间(应用程序之间)。
图 5.12。系统间通信构成了整个应用程序的可观察行为。系统内通信是实现细节。
图 5.13。清单 5.9 中的示例使用六边形架构表示。六边形之间的通信是系统间通信。六边形内部的通信是系统内通信。
图 5.14。具有无法从外部观察到的进程外依赖关系的通信是实现细节。它们不必在重构后保留在原处,因此不应使用模拟进行验证。
Figure 5.1. All variations of test doubles can be categorized into two types: mocks and stubs.
Figure 5.9. A hexagonal architecture is a set of interacting applications—hexagons.
第 6 章 单元测试风格
Chapter 6. Styles of unit testing
图 6.1。在基于输出的测试中,测试验证系统生成的输出。这种测试方式假设没有副作用,并且 SUT 工作的唯一结果是它返回给调用者的值。
图 6.2。使用输入输出符号表示的 PriceEngine。其 CalculateDiscount() 方法接受产品数组并计算折扣。
图 6.3。在基于状态的测试中,测试验证操作完成后系统的最终状态。虚线圆圈表示最终状态。
图 6.4. 在基于通信的测试中,测试用模拟替代 SUT 的协作者,并验证 SUT 是否正确调用这些协作者。
图 6.5。CalculateDiscount() 有一个输入(产品数组)和一个输出(十进制折扣)。输入和输出均在方法签名中明确表示,这使得 CalculateDiscount() 成为一个数学函数。
图 6.6。数学中函数的一个典型例子是 f(x) = x + 1。对于集合 X 中的每个输入数字 x,该函数都会在集合 Y 中找到相应的数字 y。
图 6.7。CalculateDiscount() 方法使用与函数 f(x) = x + 1 相同的符号表示。对于每个输入产品数组,该方法都会找到相应的折扣作为输出。
图 6.8。方法 AddComment(显示为 f)有一个文本输入和一个注释输出,它们都表达在方法签名中。副作用是额外的隐藏输出。
图 6.9。在功能架构中,功能核心使用数学函数实现,并在应用程序中做出所有决策。可变外壳为功能核心提供输入数据,并通过将副作用应用于进程外依赖项(例如数据库)来解释其决策。
图 6.10。六边形架构是一组交互的应用程序——六边形。您的应用程序由一个领域层和一个应用服务层组成,它们对应于功能架构中的功能核心和可变外壳。
图 6.11。审计系统将访客信息存储在特定格式的文本文件中。当达到每个文件的最大条目数时,系统会创建一个新文件。
图 6.12. 涵盖审计系统初始版本的测试必须直接与文件系统一起工作。
图 6.13。测试可以模拟文件系统并捕获审计系统对文件的写入。
图 6.16。对数据库的依赖为 AuditManager 引入了隐藏输入。这样的类不再是纯功能性的,整个应用程序不再遵循功能架构。
第 7 章 重构有价值的单元测试
Chapter 7. Refactoring toward valuable unit tests
图 7.1。四种类型的代码,按代码复杂性和领域重要性(纵轴)以及合作者数量(横轴)分类。
图 7.2. 通过将过于复杂的代码拆分为算法和控制器来重构代码。理想情况下,右上象限中不应有代码。
图 7.3。很难测试与复杂依赖项耦合的代码。测试还必须处理该依赖项,这会增加其维护成本。
图 7.4。低级对象模式从过于复杂的代码中提取逻辑,使代码变得非常低级,无需测试。提取的逻辑被移到另一个类中,与难以测试的依赖关系解耦。
图 7.6。当您考虑业务逻辑和编排职责之间的分离时,代码深度与代码宽度是一个有用的比喻。控制器编排许多依赖项(在图中表示为箭头),但本身并不复杂(复杂性表示为块高度)。域类与此相反。
图 7.7。User 类的初始实现在两个维度上得分都很高,因此属于过于复杂的代码类别。
图 7.8。第 2 种方法将 User 置于领域模型象限,靠近纵轴。UserController 几乎跨越了过于复杂象限的边界,因为它包含复杂的逻辑。
图 7.9。User 已向右移动,因为它现在有了 Company 协作者。UserController 稳稳地站在控制器象限中;它的所有复杂性都已转移到工厂。
图 7.10。当所有对进程外依赖关系的引用都可以推到业务操作的边缘时,六边形和功能架构效果最佳。
图 7.11.当你需要在业务操作中引用进程外依赖关系时,六边形架构就不能很好地发挥作用了。
第 8 章 为什么要进行集成测试?
Chapter 8. Why integration testing?
图 8.1。集成测试涵盖控制器,而单元测试涵盖领域模型和算法。琐碎和过于复杂的代码根本不应该测试。
图 8.2。测试金字塔代表了对大多数应用程序最有效的权衡。快速、廉价的单元测试涵盖了大多数边缘情况,而少量缓慢、更昂贵的集成测试则确保了整个系统的正确性。
图 8.3。简单项目的测试金字塔。与普通金字塔相比,复杂性较低的项目需要的单元测试数量较少。
图 8.4。与托管依赖项的通信是实现细节;在集成测试中按原样使用此类依赖项。与非托管依赖项的通信是系统可观察行为的一部分。此类依赖项应被模拟出来。
图 8.5. 将数据库对外部应用程序可见的部分视为非托管依赖项。在集成测试中将其替换为模拟。将数据库的其余部分视为托管依赖项。验证其最终状态,而不是与它的交互。
图 8.6。更改用户电子邮件的用例。控制器协调数据库、消息总线和域模型之间的工作。
图 8.7。端到端测试模拟外部客户端,因此测试已部署的应用程序版本,并将所有进程外依赖项都包含在测试范围内。端到端测试不应直接检查托管依赖项(例如数据库),而应通过应用程序间接检查。
图 8.8。集成测试在同一进程内托管应用程序。与端到端测试不同,集成测试用模拟替代非托管依赖项。集成测试的唯一进程外组件是托管依赖项。
图 8.9。各种应用问题通常由单独的间接层解决。典型特性占据每层的一小部分。
图 8.11。使用接口,您可以在编译时删除循环依赖,但不能在运行时删除。理解代码所需的认知负荷不会变小。
图 8.12。结构化日志记录将日志数据与该数据的呈现分离。您可以设置多个呈现,例如平面日志文件、JSON 或 CSV 文件。
第 9 章 Mocking 最佳实践
Chapter 9. Mocking best practices
图 9.1。IBus 位于系统的边缘;IMessageBus 只是控制器和消息总线之间类型链中的中间环节。模拟 IBus 而不是 IMessageBus 可实现最佳的回归保护。
第 10 章 测试数据库
Chapter 10. Testing the database
图 10.1。将专用实例作为模型数据库是一种反模式。数据库模式最好存储在源代码控制系统中。
图 10.2。基于迁移的数据库交付方法强调使用显式迁移将数据库从一个版本转换到另一个版本。
图 10.3. 基于状态的方法使状态显式化,而迁移隐式化;基于迁移的方法则做出相反的选择。
图 10.4。将每个数据库调用包装到单独的事务中会引入因硬件或软件故障而导致不一致的风险。例如,应用程序可以更新公司员工人数,但不能更新员工本身。
图 10.5。事务调解控制器和数据库之间的交互,从而实现原子数据修改。
图 10.6。工作单元在业务操作结束时执行所有更新。更新仍包含在数据库事务中,但该事务的生存期较短,从而减少了数据拥塞。
第 2 章什么是单元测试?
Chapter 2. What is a unit test?
第 4 章 良好单元测试的四大支柱
Chapter 4. The four pillars of a good unit test
Table 4.1. The pros and cons of white-box and black-box testing
第 5 章 Mocks 和测试脆弱性
Chapter 5. Mocks and test fragility
Table 5.2. The differences between the London and classical schools of unit testing
第 6 章 单元测试风格
Chapter 6. Styles of unit testing
Table 6.1. The three styles of unit testing: The comparisons
Table 6.3. The version with mocks compared to the initial version of the audit system
Table 6.4. The output-based test compared to the previous two versions
第 7 章 重构有价值的单元测试
Chapter 7. Refactoring toward valuable unit tests
Table 7.1. Types of code in the sample project after refactoring using the Humble Object pattern
第 11 章 单元测试反模式
Chapter 11. Unit testing anti-patterns
Table 11.1. The relationship between the code’s publicity and purpose
第 1 章 单元测试的目标
Chapter 1. The goal of unit testing
Listing 1.1. A sample method partially covered by a test
Listing 1.2. Version of IsStringLong that records the last result
第 2 章什么是单元测试?
Chapter 2. What is a unit test?
Listing 2.1. Tests written using the classical style of unit testing
Listing 2.2. Tests written using the London style of unit testing
第 3 章 单元测试的结构
Chapter 3. The anatomy of a unit test
Listing 3.1. A test covering the Sum method in calculator
Listing 3.2. A single-line act section
Listing 3.3. A two-line act section
Listing 3.4. Differentiating the SUT from its dependencies
Listing 3.5. Calculator with sections separated by empty lines
Listing 3.6. Arrangement and teardown logic, shared by all tests
Listing 3.7. Extracting the initialization code into the test constructor
Listing 3.8. Extracting the common initialization code into private factory methods
Listing 3.9. Common initialization code in a base class
Listing 3.10. A test named using the rigid naming policy
Listing 3.11. A test that encompasses several facts
Listing 3.12. Two tests verifying the positive and negative scenarios
Listing 3.13. Generating complex data for the parameterized test
第 4 章 良好单元测试的四大支柱
Chapter 4. The four pillars of a good unit test
清单 4.2. 验证 MessageRenderer 是否具有正确的结构
清单 4.3. 验证 MessageRenderer 类的源代码
Listing 4.1. Generating an HTML representation of a message
Listing 4.2. Verifying that MessageRenderer has the correct structure
Listing 4.3. Verifying the source code of the MessageRenderer class
Listing 4.4. Verifying the outcome that MessageRenderer produces
第 5 章 Mocks 和测试脆弱性
Chapter 5. Mocks and test fragility
Listing 5.1. Using the Mock class from a mocking library to create a mock
Listing 5.2. Using the Mock class to create a stub
Listing 5.3. Asserting an interaction with a stub
Listing 5.4. storeMock: both a mock and a stub
Listing 5.5. User class with leaking implementation details
Listing 5.6. A version of User with a well-designed API
Listing 5.7. State as an implementation detail
Listing 5.8. A domain class with an application service
Listing 5.9. Connecting the domain model with external applications
第 6 章 单元测试风格
Chapter 6. Styles of unit testing
Listing 6.1. Output-based testing
Listing 6.2. State-based testing
Listing 6.3. Communication-based testing
Listing 6.4. State verification that takes up a lot of space
Listing 6.5. Using helper methods in assertions
Listing 6.6. Comment compared by value
Listing 6.7. Modification of an internal state
Listing 6.8. Initial implementation of the audit system
Listing 6.9. Injecting the filesystem explicitly via the constructor
Listing 6.10. Using the new IFileSystem interface
Listing 6.11. Checking the audit system’s behavior using a mock
Listing 6.12. The AuditManager class after refactoring
Listing 6.13. The mutable shell acting on AuditManager’s decision
Listing 6.14. Gluing together the functional core and mutable shell
第 7 章 重构有价值的单元测试
Chapter 7. Refactoring toward valuable unit tests
Listing 7.1. Initial implementation of the CRM system
Listing 7.2. Application service, version 1
Listing 7.4. The new class in the domain layer
Listing 7.5. Controller after refactoring
Listing 7.6. User after refactoring
Listing 7.7. User with a new property
Listing 7.8. The controller, still stripped of all decision-making
Listing 7.9. Controller deciding whether to change the user’s email
Listing 7.10. Changing an email using the CanExecute/Execute pattern
Listing 7.11. Sends a notification even when the email has not changed
Listing 7.12. User adding an event when the email changes
第 8 章 为什么要进行集成测试?
Chapter 8. Why integration testing?
清单 8.4. 将支持日志记录提取到 DomainLogger 类中
清单 8.5. DomainLogger 作为 ILogger 的包装器
Listing 8.1. The user controller
Listing 8.2. The integration test
Listing 8.3. An example of logging in User
Listing 8.4. Extracting support logging into the DomainLogger class
Listing 8.5. DomainLogger as a wrapper on top of ILogger
Listing 8.6. Replacing DomainLogger in User with a domain event
Listing 8.7. Latest version of UserController
第 9 章 Mocking 最佳实践
Chapter 9. Mocking best practices
Listing 9.5. Integration test targeting IBus
Listing 9.6. A spy (also known as a handwritten mock)
第 10 章 测试数据库
Chapter 10. Testing the database
Listing 10.1. Class that enables access to the database
Listing 10.3. User controller, repositories, and a transaction
Listing 10.4. User controller with Entity Framework
Listing 10.5. Integration test reusing CrmContext
Listing 10.6. Base class for integration tests
Listing 10.7. Integration test with three database contexts
Listing 10.8. A separate method that creates a user
Listing 10.9. Adding default values to the factory
Listing 10.10. Using the factory method
Listing 10.11. Decorator method
Listing 10.12. Data assertions after extracting the querying logic
Listing 10.13. Fluent interface for data assertions
Listing 10.14. Integration test after moving all technicalities out of it
第 11 章 单元测试反模式
Chapter 11. Unit testing anti-patterns
清单 11.12. 使用 StatisticsCalculator 的控制器
Listing 11.1. A class with a complex private method
Listing 11.2. Extracting the complex private method
Listing 11.3. A class with a private constructor
Listing 11.4. A class with private state
Listing 11.5. Leaking algorithm implementation
Listing 11.6. A parameterized version of the same test
Listing 11.7. Test with no domain knowledge
Listing 11.8. Logger with a Boolean switch
Listing 11.9. A test using the Boolean switch
Listing 11.10. A version without the switch
Listing 11.11. A class that calculates statistics
Listing 11.12. A controller using StatisticsCalculator
Listing 11.13. Test that mocks the concrete class
Listing 11.14. Splitting StatisticsCalculator into two classes
Listing 11.15. Controller after the refactoring
Listing 11.16. Current date and time as an ambient context
Listing 11.17. Current date and time as an explicit dependency